Compare commits

...

1 Commits

Author SHA1 Message Date
Tushar Vats
7e08f757c8 chore: refactor qb to lossen tight coupling of querybuilder with other modules 2026-07-02 17:40:21 +05:30
32 changed files with 814 additions and 437 deletions

View File

@@ -21,6 +21,42 @@ func newConditionBuilder(fm qbtypes.FieldMapper) qbtypes.ConditionBuilder {
}
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
fieldKeysForName []*telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken are logs-body-only; reject for rule state history.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}
keys, warning := querybuilder.ResolveKeys(key, fieldKeysForName)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
if len(keys) == 0 {
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
cond, err := c.conditionForKey(ctx, startNs, endNs, k, operator, value, sb)
if err != nil {
return nil, nil, err
}
conds = append(conds, cond)
}
return conds, warnings, nil
}
func (c *conditionBuilder) conditionForKey(
ctx context.Context,
startNs uint64,
endNs uint64,

View File

@@ -220,9 +220,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
FieldKeys: v.fieldKeys,
FieldMapper: v.fieldMapper,
ConditionBuilder: v.conditionBuilder,
BodyJSONEnabled: bodyJSONEnabled,
FullTextColumn: v.fullTextColumn,
JsonKeyToKey: v.jsonKeyToKey,
StartNs: v.startNs,
EndNs: v.endNs,
},

View File

@@ -47,11 +47,11 @@ func CollisionHandledFinalExpr(
addCondition := func(key *telemetrytypes.TelemetryFieldKey) error {
sb := sqlbuilder.NewSelectBuilder()
condition, err := cb.ConditionFor(ctx, startNs, endNs, key, qbtypes.FilterOperatorExists, nil, sb)
conds, _, err := cb.ConditionFor(ctx, startNs, endNs, key, []*telemetrytypes.TelemetryFieldKey{key}, qbtypes.FilterOperatorExists, nil, sb)
if err != nil {
return err
}
sb.Where(condition)
sb.Where(conds[0])
expr, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
expr = strings.TrimPrefix(expr, "WHERE ")

View File

@@ -0,0 +1,82 @@
package querybuilder
import (
"fmt"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
// FieldContextDataTypesDocURL documents how to disambiguate a key that resolves
// to multiple field context / data type combinations.
FieldContextDataTypesDocURL = "https://signoz.io/docs/userguide/field-context-data-types/"
// KeyNotFoundDocURL documents the "key not found" error.
KeyNotFoundDocURL = "https://signoz.io/docs/userguide/search-troubleshooting/#key-fieldname-not-found"
// Doc URLs for the has/hasAny/hasAll and hasToken "unsupported" errors.
functionBodyJSONSearchDocURL = "https://signoz.io/docs/userguide/search-troubleshooting/#function-supports-only-body-json-search"
hasTokenFunctionDocURL = "https://signoz.io/docs/userguide/functions-reference/#hastoken-function"
)
// ResolveKeys picks which matching field keys a filter term builds conditions for.
// With 0 or 1 match it returns the input unchanged and no warning. When a name is
// ambiguous it returns a warning; a resource+attribute mix defaults to the resource
// keys (the common intent), noted in the warning.
func ResolveKeys(field *telemetrytypes.TelemetryFieldKey, fieldKeysForName []*telemetrytypes.TelemetryFieldKey) ([]*telemetrytypes.TelemetryFieldKey, string) {
if len(fieldKeysForName) <= 1 {
return fieldKeysForName, ""
}
warning := fmt.Sprintf(
"Key `%s` is ambiguous, found %d different combinations of field context / data type: %v.",
field.Name,
len(fieldKeysForName),
fieldKeysForName,
)
hasResource, hasAttribute := false, false
for _, item := range fieldKeysForName {
switch item.FieldContext {
case telemetrytypes.FieldContextResource:
hasResource = true
case telemetrytypes.FieldContextAttribute:
hasAttribute = true
}
}
// when there is both resource and attribute context, default to resource only
if hasResource && hasAttribute {
filteredKeys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(fieldKeysForName))
for _, item := range fieldKeysForName {
if item.FieldContext == telemetrytypes.FieldContextResource {
filteredKeys = append(filteredKeys, item)
}
}
fieldKeysForName = filteredKeys
warning += " " + "Using `resource` context by default. To query attributes explicitly, " +
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s')", field.Name)
}
return fieldKeysForName, warning
}
// NewKeyNotFoundError builds the error a condition builder returns when a filter term
// references a key it has no matching field key for.
func NewKeyNotFoundError(name string) error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "key `%s` not found", name).WithUrl(KeyNotFoundDocURL)
}
// NewFunctionUnsupportedError returns the error for a has/hasAny/hasAll/hasToken operator
// on a builder that doesn't support it (logs body only), or nil for other operators.
func NewFunctionUnsupportedError(operator qbtypes.FilterOperator) error {
switch operator {
case qbtypes.FilterOperatorHasToken:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "function `hasToken` only supports body field as first parameter").WithUrl(hasTokenFunctionDocURL)
case qbtypes.FilterOperatorHas, qbtypes.FilterOperatorHasAny, qbtypes.FilterOperatorHasAll:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "function `%s` supports only body JSON search", operator.FunctionName()).WithUrl(functionBodyJSONSearchDocURL)
default:
return nil
}
}

View File

@@ -25,7 +25,6 @@ const stringMatchingOperatorDocURL = "https://signoz.io/docs/userguide/operators
// to convert the parsed filter expressions into ClickHouse WHERE clause.
type filterExpressionVisitor struct {
context context.Context
logger *slog.Logger
fieldMapper qbtypes.FieldMapper
conditionBuilder qbtypes.ConditionBuilder
warnings []string
@@ -35,12 +34,8 @@ type filterExpressionVisitor struct {
mainErrorURL string
builder *sqlbuilder.SelectBuilder
fullTextColumn *telemetrytypes.TelemetryFieldKey
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
bodyJSONEnabled bool
skipResourceFilter bool
skipFullTextFilter bool
skipFunctionCalls bool
ignoreNotFoundKeys bool
variables map[string]qbtypes.VariableItem
keysWithWarnings map[string]bool
@@ -56,12 +51,8 @@ type FilterExprVisitorOpts struct {
FieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
Builder *sqlbuilder.SelectBuilder
FullTextColumn *telemetrytypes.TelemetryFieldKey
JsonKeyToKey qbtypes.JsonKeyToFieldFunc
BodyJSONEnabled bool
SkipResourceFilter bool
SkipFullTextFilter bool
SkipFunctionCalls bool
IgnoreNotFoundKeys bool
Variables map[string]qbtypes.VariableItem
StartNs uint64
EndNs uint64
@@ -71,18 +62,13 @@ type FilterExprVisitorOpts struct {
func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVisitor {
return &filterExpressionVisitor{
context: opts.Context,
logger: opts.Logger,
fieldMapper: opts.FieldMapper,
conditionBuilder: opts.ConditionBuilder,
fieldKeys: opts.FieldKeys,
builder: opts.Builder,
fullTextColumn: opts.FullTextColumn,
jsonKeyToKey: opts.JsonKeyToKey,
bodyJSONEnabled: opts.BodyJSONEnabled,
skipResourceFilter: opts.SkipResourceFilter,
skipFullTextFilter: opts.SkipFullTextFilter,
skipFunctionCalls: opts.SkipFunctionCalls,
ignoreNotFoundKeys: opts.IgnoreNotFoundKeys,
variables: opts.Variables,
keysWithWarnings: make(map[string]bool),
startNs: opts.StartNs,
@@ -360,16 +346,17 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
return ErrorConditionLiteral
}
}
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.startNs, v.endNs, v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(searchText), v.builder)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
conds, ok := v.buildConditions(v.fullTextColumn, []*telemetrytypes.TelemetryFieldKey{v.fullTextColumn}, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(searchText))
if !ok {
return ErrorConditionLiteral
}
if v.bodyJSONEnabled && v.fullTextColumn.Name == "body" {
v.warnings = append(v.warnings, BodyFullTextSearchDefaultWarning)
if len(conds) == 0 {
return SkipConditionLiteral
}
return cond
if len(conds) == 1 {
return conds[0]
}
return v.builder.Or(conds...)
}
return ErrorConditionLiteral // Should not happen with valid input
@@ -377,26 +364,26 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
// VisitComparison handles all comparison operators.
func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext) any {
keys := v.Visit(ctx.Key()).([]*telemetrytypes.TelemetryFieldKey)
key := v.Visit(ctx.Key()).(*telemetrytypes.TelemetryFieldKey)
matching := matchingFieldKeys(key, v.fieldKeys)
// no keys resolved; VisitKey already recorded the error, skip this condition
if len(keys) == 0 {
return ErrorConditionLiteral
}
// this is used to skip the resource filtering on main table if
// the query may use the resources table sub-query filter
if v.skipResourceFilter {
filteredKeys := []*telemetrytypes.TelemetryFieldKey{}
for _, key := range keys {
if key.FieldContext != telemetrytypes.FieldContextResource {
filteredKeys = append(filteredKeys, key)
// Skip resource filtering on the main table when a sub-query covers it. Resolve
// ambiguity first (resource+attribute defaults to resource), then drop resource
// matches; skip the term if nothing remains. Empty matches flow to the builder.
if v.skipResourceFilter && len(matching) > 0 {
resolved, warning := ResolveKeys(key, matching)
// emit the ambiguity warning even when the term is skipped below
v.addWarnings([]string{warning}, len(matching) > 1)
filtered := []*telemetrytypes.TelemetryFieldKey{}
for _, k := range resolved {
if k.FieldContext != telemetrytypes.FieldContextResource {
filtered = append(filtered, k)
}
}
keys = filteredKeys
if len(keys) == 0 {
if len(filtered) == 0 {
return SkipConditionLiteral
}
matching = filtered
}
// Handle EXISTS specially
@@ -405,18 +392,12 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotExists
}
var conds []string
for _, key := range keys {
condition, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, key, op, nil, v.builder)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build condition: %s", err.Error()))
return ErrorConditionLiteral
}
if slices.Contains(SkippableConditionLiterals, condition) {
continue
}
conds = append(conds, condition)
conds, ok := v.buildConditions(key, matching, op, nil)
if !ok {
return ErrorConditionLiteral
}
if len(conds) == 0 {
return SkipConditionLiteral
}
@@ -484,17 +465,12 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
if ctx.NotInClause() != nil {
op = qbtypes.FilterOperatorNotIn
}
var conds []string
for _, key := range keys {
condition, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, key, op, values, v.builder)
if err != nil {
return ErrorConditionLiteral
}
if slices.Contains(SkippableConditionLiterals, condition) {
continue
}
conds = append(conds, condition)
conds, ok := v.buildConditions(key, matching, op, values)
if !ok {
return ErrorConditionLiteral
}
if len(conds) == 0 {
return SkipConditionLiteral
}
@@ -525,29 +501,22 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
switch value1.(type) {
case float64:
if _, ok := value2.(float64); !ok {
v.errors = append(v.errors, fmt.Sprintf("value type mismatch for key %s: expected number for both operands", keys[0].Name))
v.errors = append(v.errors, fmt.Sprintf("value type mismatch for key %s: expected number for both operands", key.Name))
return ErrorConditionLiteral
}
case string:
if _, ok := value2.(string); !ok {
v.errors = append(v.errors, fmt.Sprintf("value type mismatch for key %s: expected string for both operands", keys[0].Name))
v.errors = append(v.errors, fmt.Sprintf("value type mismatch for key %s: expected string for both operands", key.Name))
return ErrorConditionLiteral
}
default:
v.errors = append(v.errors, fmt.Sprintf("value type mismatch for key %s: operands must be number or string", keys[0].Name))
v.errors = append(v.errors, fmt.Sprintf("value type mismatch for key %s: operands must be number or string", key.Name))
return ErrorConditionLiteral
}
var conds []string
for _, key := range keys {
condition, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, key, op, []any{value1, value2}, v.builder)
if err != nil {
return ErrorConditionLiteral
}
if slices.Contains(SkippableConditionLiterals, condition) {
continue
}
conds = append(conds, condition)
conds, ok := v.buildConditions(key, matching, op, []any{value1, value2})
if !ok {
return ErrorConditionLiteral
}
if len(conds) == 0 {
return SkipConditionLiteral
@@ -629,18 +598,11 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
}
}
var conds []string
for _, key := range keys {
condition, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, key, op, value, v.builder)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build condition: %s", err.Error()))
return ErrorConditionLiteral
}
if slices.Contains(SkippableConditionLiterals, condition) {
continue
}
conds = append(conds, condition)
conds, ok := v.buildConditions(key, matching, op, value)
if !ok {
return ErrorConditionLiteral
}
if len(conds) == 0 {
return SkipConditionLiteral
}
@@ -718,35 +680,37 @@ func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) an
v.errors = append(v.errors, "full text search is not supported")
return ErrorConditionLiteral
}
cond, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
conds, ok := v.buildConditions(v.fullTextColumn, []*telemetrytypes.TelemetryFieldKey{v.fullTextColumn}, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text))
if !ok {
return ErrorConditionLiteral
}
if v.bodyJSONEnabled && v.fullTextColumn.Name == "body" {
v.warnings = append(v.warnings, BodyFullTextSearchDefaultWarning)
if len(conds) == 0 {
return SkipConditionLiteral
}
return cond
if len(conds) == 1 {
return conds[0]
}
return v.builder.Or(conds...)
}
// VisitFunctionCall handles function calls like has(), hasAny(), etc.
func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallContext) any {
if v.skipFunctionCalls {
return SkipConditionLiteral
}
// Get function name based on which token is present
var functionName string
var operator qbtypes.FilterOperator
if ctx.HAS() != nil {
functionName = "has"
operator = qbtypes.FilterOperatorHas
} else if ctx.HASANY() != nil {
functionName = "hasAny"
operator = qbtypes.FilterOperatorHasAny
} else if ctx.HASALL() != nil {
functionName = "hasAll"
operator = qbtypes.FilterOperatorHasAll
} else if ctx.HASTOKEN() != nil {
functionName = "hasToken"
operator = qbtypes.FilterOperatorHasToken
} else {
// Default fallback
v.errors = append(v.errors, fmt.Sprintf("unknown function `%s`", ctx.GetText()))
@@ -759,86 +723,22 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
return ErrorConditionLiteral
}
keys, ok := params[0].([]*telemetrytypes.TelemetryFieldKey)
key, ok := params[0].(*telemetrytypes.TelemetryFieldKey)
if !ok {
v.errors = append(v.errors, fmt.Sprintf("function `%s` expects key parameter to be a field key", functionName))
return ErrorConditionLiteral
}
// TODO(Tushar): thread orgID here to evaluate correctly
if v.bodyJSONEnabled && functionName != "hasToken" {
filteredKeys := []*telemetrytypes.TelemetryFieldKey{}
for _, key := range keys {
if key.FieldDataType.IsArray() {
filteredKeys = append(filteredKeys, key)
}
}
if len(filteredKeys) == 0 {
v.errors = append(v.errors, fmt.Sprintf("function `%s` expects key parameter to be an array field; no array fields found", functionName))
return ErrorConditionLiteral
}
keys = filteredKeys
}
value := params[1:]
var conds []string
for _, key := range keys {
var fieldName string
if functionName == "hasToken" {
if key.Name != "body" {
if v.mainErrorURL == "" {
v.mainErrorURL = "https://signoz.io/docs/userguide/functions-reference/#hastoken-function"
}
v.errors = append(v.errors, fmt.Sprintf("function `%s` only supports body field as first parameter", functionName))
}
// this will only work with string.
if _, ok := value[0].(string); !ok {
if v.mainErrorURL == "" {
v.mainErrorURL = "https://signoz.io/docs/userguide/functions-reference/#hastoken-function"
}
v.errors = append(v.errors, fmt.Sprintf("function `%s` expects value parameter to be a string", functionName))
return ErrorConditionLiteral
}
conds = append(conds, fmt.Sprintf("hasToken(LOWER(%s), LOWER(%s))", key.Name, v.builder.Var(value[0])))
} else {
// this is that all other functions only support array fields
if key.FieldContext == telemetrytypes.FieldContextBody {
var err error
if v.bodyJSONEnabled {
fieldName, err = v.fieldMapper.FieldFor(v.context, v.startNs, v.endNs, key)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to get field name for key %s: %s", key.Name, err.Error()))
return ErrorConditionLiteral
}
} else {
fieldName, _ = v.jsonKeyToKey(v.context, key, qbtypes.FilterOperatorUnknown, value)
}
} else {
// TODO(add docs for json body search)
if v.mainErrorURL == "" {
v.mainErrorURL = "https://signoz.io/docs/userguide/search-troubleshooting/#function-supports-only-body-json-search"
}
v.errors = append(v.errors, fmt.Sprintf("function `%s` supports only body JSON search", functionName))
return ErrorConditionLiteral
}
var cond string
// Map our functions to ClickHouse equivalents
switch functionName {
case "has":
cond = fmt.Sprintf("has(%s, %s)", fieldName, v.builder.Var(value[0]))
case "hasAny":
cond = fmt.Sprintf("hasAny(%s, %s)", fieldName, v.builder.Var(value[0]))
case "hasAll":
cond = fmt.Sprintf("hasAll(%s, %s)", fieldName, v.builder.Var(value[0]))
}
conds = append(conds, cond)
}
conds, ok := v.buildConditions(key, matchingFieldKeys(key, v.fieldKeys), operator, value)
if !ok {
return ErrorConditionLiteral
}
if len(conds) == 0 {
return SkipConditionLiteral
}
if len(conds) == 1 {
return conds[0]
}
@@ -903,106 +803,41 @@ func (v *filterExpressionVisitor) VisitValue(ctx *grammar.ValueContext) any {
return ErrorConditionLiteral // Should not happen with valid input
}
// VisitKey handles field/column references.
// VisitKey resolves a field/column reference to its parsed key. It makes no
// decisions — the condition builder owns those.
func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(ctx.GetText())
keyName := fieldKey.Name
return &fieldKey
}
// GetFieldKeyFromKeyText function extracts the context prefix (attribute., resource., body.) if present
// extracts data type if present (e.g., :string, :int)
// so we need to filter the available field keys based on the provided context and data type
fieldKeysForName := []*telemetrytypes.TelemetryFieldKey{}
// buildConditions invokes the condition builder for a filter term, folding its
// warnings/errors into visitor state. It returns the conditions and false if an error
// was recorded.
func (v *filterExpressionVisitor) buildConditions(key *telemetrytypes.TelemetryFieldKey, matching []*telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) ([]string, bool) {
conds, warns, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, key, matching, op, value, v.builder)
if err != nil {
_, _, _, _, errURL, _ := errors.Unwrapb(err)
assignIfEmpty(&v.mainErrorURL, errURL)
v.errors = append(v.errors, err.Error())
return nil, false
}
v.addWarnings(warns, len(matching) > 1)
return conds, true
}
// If user said key = 'xyz', we look up in the map for all possible field keys with name 'xyz'
for _, item := range v.fieldKeys[keyName] {
// Match items where:
// 1) user didn't specify context OR context matches, AND
// 2) user didn't specify data type OR data type matches.
if (fieldKey.FieldContext == telemetrytypes.FieldContextUnspecified || fieldKey.FieldContext == item.FieldContext) &&
(fieldKey.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || fieldKey.FieldDataType == item.FieldDataType) {
fieldKeysForName = append(fieldKeysForName, item)
// addWarnings appends de-duplicated warnings to the visitor. ambiguous marks warnings
// from a multi-match key so the field-context doc URL is attached.
func (v *filterExpressionVisitor) addWarnings(warns []string, ambiguous bool) {
for _, w := range warns {
if w == "" || v.keysWithWarnings[w] {
continue
}
v.keysWithWarnings[w] = true
v.warnings = append(v.warnings, w)
if ambiguous {
assignIfEmpty(&v.mainWarnURL, FieldContextDataTypesDocURL)
}
}
// Now consider that GetFieldKeyFromKeyText may have extracted the context which was actually part of the name
// If user said attribute.key = 'xyz',
// 1. either user meant key ( this is already handled above in fieldKeysForName )
// 2. or user meant `attribute.key` we look up in the map for all possible field keys with name 'attribute.key'
// Note:
// If user only wants to search `attribute.key`, then they have to use `attribute.attribute.key`
// If user only wants to search `key`, then they have to use `key`
// If user wants to search both, they can use `attribute.key` and we will resolve the ambiguity
if fieldKey.FieldContext != telemetrytypes.FieldContextUnspecified {
contextPrefixedFieldName := fmt.Sprintf("%s.%s", fieldKey.FieldContext.StringValue(), fieldKey.Name)
for _, item := range v.fieldKeys[contextPrefixedFieldName] {
// Match items where: user didn't specify data type OR data type matches. Context filtering not needed here since context was used to construct the lookup key.
if fieldKey.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || item.FieldDataType == fieldKey.FieldDataType {
fieldKeysForName = append(fieldKeysForName, item)
}
}
}
// for the body json search, we need to add search on the body field even
// if there is a field with the same name as attribute/resource attribute
// Since it will ORed with the fieldKeysForName, it will not result empty
// when either of them have values
// Note: Skip this logic if body json query is enabled so we can look up the key inside fields
//
// TODO(Piyush): After entire migration this is supposed to be removed.
// TODO(Tushar): thread orgID here to evaluate correctly
if fieldKey.FieldContext == telemetrytypes.FieldContextBody && !v.bodyJSONEnabled {
fieldKeysForName = append(fieldKeysForName, &fieldKey)
}
if len(fieldKeysForName) == 0 {
if fieldKey.FieldContext == telemetrytypes.FieldContextBody && keyName == "" {
v.errors = append(v.errors, "missing key for body json search - expected key of the form `body.key` (ex: `body.status`)")
} else if !v.ignoreNotFoundKeys {
// TODO(srikanthccv): do we want to return an error here?
// should we infer the type and auto-magically build a key for expression?
v.errors = append(v.errors, fmt.Sprintf("key `%s` not found", fieldKey.Name))
v.mainErrorURL = "https://signoz.io/docs/userguide/search-troubleshooting/#key-fieldname-not-found"
}
}
if len(fieldKeysForName) > 1 {
warnMsg := fmt.Sprintf(
"Key `%s` is ambiguous, found %d different combinations of field context / data type: %v.",
fieldKey.Name,
len(fieldKeysForName),
fieldKeysForName,
)
mixedFieldContext := map[string]bool{}
for _, item := range fieldKeysForName {
mixedFieldContext[item.FieldContext.StringValue()] = true
}
// when there is both resource and attribute context, default to resource only
if mixedFieldContext[telemetrytypes.FieldContextResource.StringValue()] &&
mixedFieldContext[telemetrytypes.FieldContextAttribute.StringValue()] {
filteredKeys := []*telemetrytypes.TelemetryFieldKey{}
for _, item := range fieldKeysForName {
if item.FieldContext != telemetrytypes.FieldContextResource {
continue
}
filteredKeys = append(filteredKeys, item)
}
fieldKeysForName = filteredKeys
warnMsg += " " + "Using `resource` context by default. To query attributes explicitly, " +
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s')", fieldKey.Name)
}
if !v.keysWithWarnings[keyName] {
v.mainWarnURL = "https://signoz.io/docs/userguide/field-context-data-types/"
// this is warning state, we must have a unambiguous key
v.warnings = append(v.warnings, warnMsg)
}
v.keysWithWarnings[keyName] = true
v.logger.Warn("ambiguous key", slog.String("field_key_name", fieldKey.Name)) //nolint:sloglint
}
return fieldKeysForName
}
// hasLikeWildcards checks if a value contains LIKE wildcards (% or _).
@@ -1028,3 +863,38 @@ func trimQuotes(txt string) string {
txt = strings.ReplaceAll(txt, `\'`, `'`)
return txt
}
func assignIfEmpty(s *string, value string) {
if *s == "" {
*s = value
}
}
// matchingFieldKeys returns the field keys from the provided map that match the given
// key, honoring any context/data type the user specified. It also resolves the case
// where GetFieldKeyFromKeyText split a context off a name that legitimately contained it.
func matchingFieldKeys(field *telemetrytypes.TelemetryFieldKey, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey {
fieldKeysForName := []*telemetrytypes.TelemetryFieldKey{}
// match by name; keep items whose context and data type match (unspecified matches any)
for _, item := range fieldKeys[field.Name] {
if (field.FieldContext == telemetrytypes.FieldContextUnspecified || field.FieldContext == item.FieldContext) &&
(field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || field.FieldDataType == item.FieldDataType) {
fieldKeysForName = append(fieldKeysForName, item)
}
}
// A context may have been split off a name that legitimately contained it (e.g.
// `attribute.key`); also look up the context-prefixed name so both readings resolve.
if field.FieldContext != telemetrytypes.FieldContextUnspecified {
contextPrefixedFieldName := fmt.Sprintf("%s.%s", field.FieldContext.StringValue(), field.Name)
for _, item := range fieldKeys[contextPrefixedFieldName] {
// Context already matched via the lookup key; only data type needs checking.
if field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || item.FieldDataType == field.FieldDataType {
fieldKeysForName = append(fieldKeysForName, item)
}
}
}
return fieldKeysForName
}

View File

@@ -3,10 +3,10 @@ package querybuilder
import (
"context"
"fmt"
"log/slog"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/errors"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -79,15 +79,13 @@ func TestPrepareWhereClause_EmptyVariableList(t *testing.T) {
}
// createTestVisitor creates a filterExpressionVisitor for testing VisitKey.
func createTestVisitor(t *testing.T, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey, ignoreNotFoundKeys bool) *filterExpressionVisitor {
func createTestVisitor(t *testing.T, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) *filterExpressionVisitor {
t.Helper()
return &filterExpressionVisitor{
context: t.Context(),
logger: slog.Default(),
fieldKeys: fieldKeys,
ignoreNotFoundKeys: ignoreNotFoundKeys,
keysWithWarnings: make(map[string]bool),
builder: sqlbuilder.NewSelectBuilder(),
context: t.Context(),
fieldKeys: fieldKeys,
keysWithWarnings: make(map[string]bool),
builder: sqlbuilder.NewSelectBuilder(),
}
}
@@ -160,7 +158,7 @@ func TestVisitKey(t *testing.T) {
name: "Key not found",
keyText: "unknown_key",
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
"service": []*telemetrytypes.TelemetryFieldKey{
"service": {
{
Name: "service",
Signal: telemetrytypes.SignalLogs,
@@ -361,7 +359,7 @@ func TestVisitKey(t *testing.T) {
name: "Unknown key with ignoreNotFoundKeys=true",
keyText: "unknown_key",
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
"service": []*telemetrytypes.TelemetryFieldKey{
"service": {
{
Name: "service",
Signal: telemetrytypes.SignalLogs,
@@ -574,7 +572,7 @@ func TestVisitKey(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
visitor := createTestVisitor(t, tt.fieldKeys, tt.ignoreNotFoundKeys)
visitor := createTestVisitor(t, tt.fieldKeys)
keyCtx := parseKeyContext(tt.keyText)
if keyCtx == nil {
@@ -582,10 +580,30 @@ func TestVisitKey(t *testing.T) {
}
result := visitor.VisitKey(keyCtx)
keys, ok := result.([]*telemetrytypes.TelemetryFieldKey)
key, ok := result.(*telemetrytypes.TelemetryFieldKey)
if !ok {
t.Fatalf("expected []*TelemetryFieldKey, got %T", result)
t.Fatalf("expected *TelemetryFieldKey, got %T", result)
}
// VisitKey only parses; the condition builder matches, resolves ambiguity
// and decides not-found handling. Replay that here against the generic
// builder behavior (error unless the key is ignored).
matching := matchingFieldKeys(key, tt.fieldKeys)
keys, warning := ResolveKeys(key, matching)
var gotErrors []string
var gotMainErrURL, gotMainWrnURL string
var gotWarnings []string
if len(keys) == 0 && !tt.ignoreNotFoundKeys {
err := NewKeyNotFoundError(key.Name)
gotErrors = append(gotErrors, err.Error())
_, _, _, _, gotMainErrURL, _ = errors.Unwrapb(err)
}
if warning != "" {
gotWarnings = append(gotWarnings, warning)
if len(matching) > 1 {
gotMainWrnURL = FieldContextDataTypesDocURL
}
}
// Check expected keys count
@@ -612,55 +630,55 @@ func TestVisitKey(t *testing.T) {
// Check errors
if tt.expectedErrors != nil {
if len(visitor.errors) != len(tt.expectedErrors) {
t.Errorf("expected %d errors, got %d: %v", len(tt.expectedErrors), len(visitor.errors), visitor.errors)
if len(gotErrors) != len(tt.expectedErrors) {
t.Errorf("expected %d errors, got %d: %v", len(tt.expectedErrors), len(gotErrors), gotErrors)
}
for _, expectedError := range tt.expectedErrors {
found := false
for _, err := range visitor.errors {
for _, err := range gotErrors {
if strings.Contains(err, expectedError) {
found = true
break
}
}
if !found {
t.Errorf("expected error containing %q, got errors: %v", expectedError, visitor.errors)
t.Errorf("expected error containing %q, got errors: %v", expectedError, gotErrors)
}
}
} else {
if len(visitor.errors) != 0 {
t.Errorf("expected no errors, got %d: %v", len(visitor.errors), visitor.errors)
if len(gotErrors) != 0 {
t.Errorf("expected no errors, got %d: %v", len(gotErrors), gotErrors)
}
}
// Check mainErrorURL
if visitor.mainErrorURL != tt.expectedMainErrURL {
t.Errorf("expected mainErrorURL %q, got %q", tt.expectedMainErrURL, visitor.mainErrorURL)
if gotMainErrURL != tt.expectedMainErrURL {
t.Errorf("expected mainErrorURL %q, got %q", tt.expectedMainErrURL, gotMainErrURL)
}
// Check warnings
if tt.expectedWarnings != nil {
for _, expectedWarn := range tt.expectedWarnings {
found := false
for _, warn := range visitor.warnings {
for _, warn := range gotWarnings {
if strings.Contains(strings.ToLower(warn), strings.ToLower(expectedWarn)) {
found = true
break
}
}
if !found {
t.Errorf("expected warning containing %q, got warnings: %v", expectedWarn, visitor.warnings)
t.Errorf("expected warning containing %q, got warnings: %v", expectedWarn, gotWarnings)
}
}
} else {
if len(visitor.warnings) != 0 {
t.Errorf("expected no warnings, got %d: %v", len(visitor.warnings), visitor.warnings)
if len(gotWarnings) != 0 {
t.Errorf("expected no warnings, got %d: %v", len(gotWarnings), gotWarnings)
}
}
// Check mainWarnURL
if visitor.mainWarnURL != tt.expectedMainWrnURL {
t.Errorf("expected mainWarnURL %q, got %q", tt.expectedMainWrnURL, visitor.mainWarnURL)
if gotMainWrnURL != tt.expectedMainWrnURL {
t.Errorf("expected mainWarnURL %q, got %q", tt.expectedMainWrnURL, gotMainWrnURL)
}
})
}
@@ -673,13 +691,12 @@ func TestVisitKey(t *testing.T) {
// This suite exercises the visitor with two different configurations
// side-by-side for each expression, asserting both expected outputs:
//
// resourceConditionBuilder (wantRSB) — returns TrueConditionLiteral for
// non-resource keys and "{name}_cond" for resource keys (x, y, z).
// Opts: SkipFullTextFilter:true, SkipFunctionCalls:true, IgnoreNotFoundKeys:true.
// resourceConditionBuilder (wantRSB) — produces "{name}_cond" only for resource
// keys (x, y, z); non-resource keys, unknown keys, and function calls yield no
// condition. Opts: SkipFullTextFilter:true.
//
// conditionBuilder (wantSB) — returns "{name}_cond" for every key regardless
// of FieldContext. Opts: SkipFullTextFilter:false, SkipFunctionCalls:false,
// IgnoreNotFoundKeys:false, FullTextColumn:bodyCol.
// of FieldContext. Opts: SkipFullTextFilter:false, FullTextColumn:bodyCol.
//
// Key behavioral rules:
//
@@ -732,16 +749,32 @@ func (b *resourceConditionBuilder) ConditionFor(
_ uint64,
_ uint64,
key *telemetrytypes.TelemetryFieldKey,
_ qbtypes.FilterOperator,
fieldKeysForName []*telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
_ any,
_ *sqlbuilder.SelectBuilder,
) (string, error) {
) ([]string, []string, error) {
if key.FieldContext != telemetrytypes.FieldContextResource {
return SkipConditionLiteral, nil
// mirror the real resource builder: function operators never apply to resources
if operator.IsFunctionOperator() {
return nil, nil, nil
}
return fmt.Sprintf("%s_cond", key.Name), nil
keys, warning := ResolveKeys(key, fieldKeysForName)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
var conds []string
for _, k := range keys {
// only resource keys contribute; others (and unknown keys) are ignored
if k.FieldContext != telemetrytypes.FieldContextResource {
continue
}
conds = append(conds, fmt.Sprintf("%s_cond", k.Name))
}
return conds, warnings, nil
}
type conditionBuilder struct{}
@@ -751,19 +784,44 @@ func (b *conditionBuilder) ConditionFor(
_ uint64,
_ uint64,
key *telemetrytypes.TelemetryFieldKey,
_ qbtypes.FilterOperator,
fieldKeysForName []*telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
_ any,
_ *sqlbuilder.SelectBuilder,
) (string, error) {
) ([]string, []string, error) {
return fmt.Sprintf("%s_cond", key.Name), nil
// has/hasAny/hasAll/hasToken only support body fields; mirror the real
// condition builder which now owns this validation and errors for non-body keys.
switch operator {
case qbtypes.FilterOperatorHas, qbtypes.FilterOperatorHasAny, qbtypes.FilterOperatorHasAll, qbtypes.FilterOperatorHasToken:
if key.FieldContext != telemetrytypes.FieldContextBody {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "function supports only body JSON search")
}
return []string{fmt.Sprintf("%s_cond", key.Name)}, nil, nil
}
keys, warning := ResolveKeys(key, fieldKeysForName)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
if len(keys) == 0 {
// errors on unknown keys (no IgnoreNotFoundKeys equivalent for this builder)
return nil, warnings, NewKeyNotFoundError(key.Name)
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
conds = append(conds, fmt.Sprintf("%s_cond", k.Name))
}
return conds, warnings, nil
}
// visitComparisonCase is a single test case for the TestVisitComparison_* family.
// Each case is run under two independent configurations:
//
// - rsbOpts (resourceConditionBuilder): skips full-text and function calls,
// ignores unknown keys, produces conditions only for resource-context keys.
// - rsbOpts (resourceConditionBuilder): skips full-text; its builder skips function
// calls and unknown keys, producing conditions only for resource-context keys.
//
// - sbOpts (conditionBuilder): skips resource-context keys (unless OR is present),
// evaluates full-text, errors on unknown keys.
@@ -799,8 +857,6 @@ func visitComparisonOpts(t *testing.T) (rsbOpts, sbOpts FilterExprVisitorOpts) {
Variables: allVariable,
SkipResourceFilter: false,
SkipFullTextFilter: true,
SkipFunctionCalls: true,
IgnoreNotFoundKeys: true,
}
sbOpts = FilterExprVisitorOpts{
Context: t.Context(),
@@ -809,8 +865,6 @@ func visitComparisonOpts(t *testing.T) (rsbOpts, sbOpts FilterExprVisitorOpts) {
Variables: allVariable,
SkipResourceFilter: true,
SkipFullTextFilter: false,
SkipFunctionCalls: false,
IgnoreNotFoundKeys: false,
FullTextColumn: bodyCol,
}
return
@@ -1537,9 +1591,9 @@ func TestVisitComparison_AllVariable(t *testing.T) {
}
// TestVisitComparison_FunctionCalls covers function call expressions (has, hasAny, hasAll).
// rsbOpts has SkipFunctionCalls=true → TrueConditionLiteral (function never evaluated).
// sbOpts has SkipFunctionCalls=false; has/hasAny/hasAll only support FieldContextBody,
// so calls on attribute/resource keys return an error.
// The resource builder skips function operators, so they yield no condition in RSB.
// In SB, has/hasAny/hasAll only support FieldContextBody, so calls on attribute/resource
// keys return an error.
func TestVisitComparison_FunctionCalls(t *testing.T) {
rsbOpts, sbOpts := visitComparisonOpts(t)
tests := []visitComparisonCase{

View File

@@ -171,6 +171,42 @@ func (c *conditionBuilder) conditionFor(
}
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
fieldKeysForName []*telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken are logs-body-only; reject for audit.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}
keys, warning := querybuilder.ResolveKeys(key, fieldKeysForName)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
if len(keys) == 0 {
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
cond, err := c.conditionForKey(ctx, startNs, endNs, k, operator, value, sb)
if err != nil {
return nil, nil, err
}
conds = append(conds, cond)
}
return conds, warnings, nil
}
func (c *conditionBuilder) conditionForKey(
ctx context.Context,
startNs uint64,
endNs uint64,

View File

@@ -49,7 +49,6 @@ func NewAuditQueryStatementBuilder(
telemetrytypes.SourceAudit,
metadataStore,
fullTextColumn,
jsonKeyToKey,
flagger,
)
@@ -551,7 +550,6 @@ func (b *auditQueryStatementBuilder) addFilterCondition(
FieldKeys: keys,
SkipResourceFilter: true,
FullTextColumn: b.fullTextColumn,
JsonKeyToKey: b.jsonKeyToKey,
Variables: variables,
StartNs: start,
EndNs: end,

View File

@@ -25,6 +25,97 @@ func NewConditionBuilder(fm qbtypes.FieldMapper, fl flagger.Flagger) *conditionB
return &conditionBuilder{fm: fm, fl: fl}
}
// isBodyJSONSearch reports whether a key addresses a path within the body JSON. Only
// an explicit Body context qualifies; a bare, context-less `body` (e.g. full-text
// `count_distinct(body)` or `body EXISTS`) is a full-text match, not a `$.body` path.
func isBodyJSONSearch(key *telemetrytypes.TelemetryFieldKey, columns []*schema.Column) bool {
if key.FieldContext != telemetrytypes.FieldContextBody {
return false
}
for _, column := range columns {
if column.Name == LogsV2BodyColumn || column.Name == LogsV2BodyV2Column {
return true
}
}
return false
}
// conditionForArrayFunction builds `has/hasAny/hasAll(<arrayFieldExpr>, value)` over a
// body JSON array field. The field expression uses the JSON accessor (flag on) or
// legacy string extraction (flag off); value[0] is the needle.
func (c *conditionBuilder) conditionForArrayFunction(
ctx context.Context,
startNs, endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
columns []*schema.Column,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
if !isBodyJSONSearch(key, columns) {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `%s` supports only body JSON search", operator.FunctionName()).WithUrl(functionBodyJSONSearchDocURL)
}
needle := value
if args, ok := value.([]any); ok && len(args) > 0 {
needle = args[0]
}
var fieldExpr string
if c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})) {
fe, err := c.fm.FieldFor(ctx, startNs, endNs, key)
if err != nil {
return "", err
}
fieldExpr = fe
} else {
// legacy string-body path; value drives array-type inference (e.g. `[*]` paths)
fieldExpr, _ = GetBodyJSONKey(ctx, key, qbtypes.FilterOperatorUnknown, value)
}
return fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), fieldExpr, sb.Var(needle)), nil
}
// conditionForHasToken builds `hasToken(LOWER(<bodyColumn>), LOWER(<needle>))`, a
// full-text token search over the body column. It resolves the column from the key
// name + use_json_body flag, validates the field/value, and tags errors with the doc URL.
func (c *conditionBuilder) conditionForHasToken(
ctx context.Context,
key *telemetrytypes.TelemetryFieldKey,
value any,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
// hasToken takes a single needle; unwrap it from the function-argument slice.
needle := value
if args, ok := value.([]any); ok && len(args) > 0 {
needle = args[0]
}
// TODO(Tushar): thread orgID here to evaluate correctly
bodyJSONEnabled := c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
columnName := LogsV2BodyColumn
if bodyJSONEnabled {
if key.Name != LogsV2BodyColumn && key.Name != bodyMessageField {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `hasToken` only supports body/body.message field as first parameter").WithUrl(hasTokenFunctionDocURL)
}
columnName = bodyMessageField
} else if key.Name != LogsV2BodyColumn {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `hasToken` only supports body field as first parameter").WithUrl(hasTokenFunctionDocURL)
}
// hasToken matches string tokens only.
if _, ok := needle.(string); !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `hasToken` expects value parameter to be a string").WithUrl(hasTokenFunctionDocURL)
}
return fmt.Sprintf("hasToken(LOWER(%s), LOWER(%s))", columnName, sb.Var(needle)), nil
}
func (c *conditionBuilder) conditionFor(
ctx context.Context,
startNs, endNs uint64,
@@ -33,15 +124,27 @@ func (c *conditionBuilder) conditionFor(
value any,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
// hasToken is a token search over the body column resolved purely from the key
// name + flag, independent of column resolution, so handle it before anything else.
if operator == qbtypes.FilterOperatorHasToken {
return c.conditionForHasToken(ctx, key, value, sb)
}
columns, err := c.fm.ColumnFor(ctx, startNs, endNs, key)
if err != nil {
return "", err
}
// has/hasAny/hasAll build `has(<arrayFieldExpr>, value)` over body JSON arrays
// rather than going through the normal operator paths, so handle them up front.
if operator.IsArrayFunctionOperator() {
return c.conditionForArrayFunction(ctx, startNs, endNs, key, operator, value, columns, sb)
}
// TODO(Piyush): Update this to support multiple JSON columns based on evolutions
for _, column := range columns {
// TODO(Tushar): thread orgID here to evaluate correctly
if column.Type.GetType() == schema.ColumnTypeEnumJSON && key.FieldContext == telemetrytypes.FieldContextBody && c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})) && key.Name != messageSubField {
if column.Type.GetType() == schema.ColumnTypeEnumJSON && isBodyJSONSearch(key, columns) && c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})) && key.Name != messageSubField {
valueType, value := InferDataType(value, operator, key)
cond, err := NewJSONConditionBuilder(key, valueType).buildJSONCondition(operator, value, sb)
if err != nil {
@@ -60,9 +163,9 @@ func (c *conditionBuilder) conditionFor(
return "", err
}
// Check if this is a body JSON search - either by FieldContext
// Check if this is a body JSON search (legacy string-body path, JSON flag off).
// TODO(Tushar): thread orgID here to evaluate correctly
if key.FieldContext == telemetrytypes.FieldContextBody && !c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})) {
if isBodyJSONSearch(key, columns) && !c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})) {
fieldExpression, value = GetBodyJSONKey(ctx, key, operator, value)
}
@@ -174,7 +277,7 @@ func (c *conditionBuilder) conditionFor(
// key membership checks, so depending on the column type, the condition changes
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
// TODO(Tushar): thread orgID here to evaluate correctly
if key.FieldContext == telemetrytypes.FieldContextBody && !c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})) {
if isBodyJSONSearch(key, columns) && !c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})) {
if operator == qbtypes.FilterOperatorExists {
return GetBodyJSONKeyForExists(ctx, key, operator, value), nil
} else {
@@ -268,6 +371,82 @@ func (c *conditionBuilder) conditionFor(
}
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
fieldKeysForName []*telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
keys, warning := querybuilder.ResolveKeys(key, fieldKeysForName)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
if len(keys) == 0 {
// No known field key matched. Legacy string-body mode still searches unknown
// Body-context keys as body JSON paths; JSON-body mode requires a metadata match.
// TODO(Tushar): thread orgID here to evaluate correctly
switch {
case key.FieldContext == telemetrytypes.FieldContextBody && key.Name == "":
return nil, warnings, errors.NewInvalidInputf(errors.CodeInvalidInput, "missing key for body json search - expected key of the form `body.key` (ex: `body.status`)")
case key.FieldContext == telemetrytypes.FieldContextBody &&
!c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})):
keys = []*telemetrytypes.TelemetryFieldKey{key}
default:
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
}
}
// has/hasAny/hasAll need an array field: in JSON-body mode drop non-array matches so a
// scalar errors clearly instead of failing at ClickHouse runtime (legacy mode skips this).
if operator.IsArrayFunctionOperator() &&
c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})) {
arrayKeys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(keys))
for _, k := range keys {
if k.FieldDataType.IsArray() {
arrayKeys = append(arrayKeys, k)
}
}
if len(arrayKeys) == 0 {
return nil, warnings, errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `%s` expects key parameter to be an array field; no array fields found", operator.FunctionName())
}
keys = arrayKeys
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
cond, err := c.conditionForKey(ctx, startNs, endNs, k, operator, value, sb)
if err != nil {
return nil, nil, err
}
conds = append(conds, cond)
if w := c.bodyFullTextDefaultWarning(ctx, startNs, endNs, k, operator); w != "" {
warnings = append(warnings, w)
}
}
return conds, warnings, nil
}
// bodyFullTextDefaultWarning returns the advisory shown when a regexp full-text
// search on `body` resolves to the body.message sub-field (JSON mode), else "". This
// keeps the JSON-vs-legacy decision in the builder rather than the filter visitor.
func (c *conditionBuilder) bodyFullTextDefaultWarning(ctx context.Context, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey, operator qbtypes.FilterOperator) string {
if operator != qbtypes.FilterOperatorRegexp || key.Name != LogsV2BodyColumn {
return ""
}
if field, err := c.fm.FieldFor(ctx, startNs, endNs, key); err == nil && field == messageSubColumn {
return querybuilder.BodyFullTextSearchDefaultWarning
}
return ""
}
func (c *conditionBuilder) conditionForKey(
ctx context.Context,
startNs uint64,
endNs uint64,

View File

@@ -131,8 +131,8 @@ func TestExistsConditionForWithEvolutions(t *testing.T) {
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.ConditionFor(ctx, tc.startTs, tc.endTs, &tc.key, tc.operator, tc.value, sb)
sb.Where(cond)
cond, _, err := conditionBuilder.ConditionFor(ctx, tc.startTs, tc.endTs, &tc.key, []*telemetrytypes.TelemetryFieldKey{&tc.key}, tc.operator, tc.value, sb)
sb.Where(cond...)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)
@@ -522,8 +522,8 @@ func TestConditionFor(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
tc.key.Evolutions = tc.evolutions
cond, err := conditionBuilder.ConditionFor(ctx, 0, 0, &tc.key, tc.operator, tc.value, sb)
sb.Where(cond)
cond, _, err := conditionBuilder.ConditionFor(ctx, 0, 0, &tc.key, []*telemetrytypes.TelemetryFieldKey{&tc.key}, tc.operator, tc.value, sb)
sb.Where(cond...)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)

View File

@@ -44,6 +44,16 @@ const (
messageSubField = "message"
messageSubColumn = "body_v2.message"
bodySearchDefaultWarning = "body searches default to `body.message:string`. Use `body.<key>` to search a different field inside body"
// bodyMessageField is the field name addressing the message sub-field of the
// body when use_json_body is enabled (i.e. `body` + `.` + `message`). hasToken
// targets this column in that mode.
bodyMessageField = "body.message"
// Documentation URLs attached to function-call errors so the visitor can
// surface them to the user without knowing function-specific details.
hasTokenFunctionDocURL = "https://signoz.io/docs/userguide/functions-reference/#hastoken-function"
functionBodyJSONSearchDocURL = "https://signoz.io/docs/userguide/search-troubleshooting/#function-supports-only-body-json-search"
)
var (

View File

@@ -28,7 +28,6 @@ func TestLikeAndILikeWithoutWildcards_Warns(t *testing.T) {
ConditionBuilder: cb,
FieldKeys: keys,
FullTextColumn: DefaultFullTextColumn,
JsonKeyToKey: GetBodyJSONKey,
}
tests := []string{
@@ -66,9 +65,7 @@ func TestLikeAndILikeWithWildcards_NoWarn(t *testing.T) {
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: keys,
FullTextColumn: DefaultFullTextColumn,
JsonKeyToKey: GetBodyJSONKey,
}
FullTextColumn: DefaultFullTextColumn}
tests := []string{
"service.name LIKE 'demo-%'",

View File

@@ -30,7 +30,6 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
ConditionBuilder: cb,
FieldKeys: keys,
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "body"},
JsonKeyToKey: GetBodyJSONKey,
}
testCases := []struct {

View File

@@ -50,7 +50,6 @@ func TestFilterExprLogs(t *testing.T) {
ConditionBuilder: cb,
FieldKeys: keys,
FullTextColumn: DefaultFullTextColumn,
JsonKeyToKey: GetBodyJSONKey,
StartNs: uint64(releaseTime.Add(-5 * time.Minute).UnixNano()),
EndNs: uint64(releaseTime.Add(5 * time.Minute).UnixNano()),
}
@@ -2467,7 +2466,6 @@ func TestFilterExprLogsConflictNegation(t *testing.T) {
ConditionBuilder: cb,
FieldKeys: keys,
FullTextColumn: DefaultFullTextColumn,
JsonKeyToKey: GetBodyJSONKey,
}
testCases := []struct {

View File

@@ -58,7 +58,6 @@ func NewLogQueryStatementBuilder(
telemetrytypes.SourceUnspecified,
metadataStore,
fullTextColumn,
jsonKeyToKey,
fl,
telemetryStore,
skipResourceFingerprintThreshold,
@@ -659,8 +658,6 @@ func (b *logQueryStatementBuilder) addFilterCondition(
var preparedWhereClause querybuilder.PreparedWhereClause
var err error
// TODO(Tushar): thread orgID here to evaluate correctly
bodyJSONEnabled := b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
if query.Filter != nil && query.Filter.Expression != "" {
// add filter expression
@@ -670,10 +667,8 @@ func (b *logQueryStatementBuilder) addFilterCondition(
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
BodyJSONEnabled: bodyJSONEnabled,
SkipResourceFilter: skipResourceFilter,
FullTextColumn: b.fullTextColumn,
JsonKeyToKey: b.jsonKeyToKey,
Variables: variables,
StartNs: start,
EndNs: end,

View File

@@ -20,6 +20,40 @@ func NewConditionBuilder(fm qbtypes.FieldMapper) *conditionBuilder {
}
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
tsStart, tsEnd uint64,
key *telemetrytypes.TelemetryFieldKey,
fieldKeysForName []*telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken are logs-body-only; reject to avoid malformed related-values SQL.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}
// metadata builds best-effort filters for related-values lookups; an unknown key
// simply yields no condition rather than an error.
keys, warning := querybuilder.ResolveKeys(key, fieldKeysForName)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
cond, err := c.conditionForKey(ctx, tsStart, tsEnd, k, operator, value, sb)
if err != nil {
return nil, nil, err
}
conds = append(conds, cond)
}
return conds, warnings, nil
}
func (c *conditionBuilder) conditionForKey(
ctx context.Context,
tsStart, tsEnd uint64,
key *telemetrytypes.TelemetryFieldKey,

View File

@@ -53,8 +53,8 @@ func TestConditionFor(t *testing.T) {
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.ConditionFor(ctx, 0, 0, &tc.key, tc.operator, tc.value, sb)
sb.Where(cond)
cond, _, err := conditionBuilder.ConditionFor(ctx, 0, 0, &tc.key, []*telemetrytypes.TelemetryFieldKey{&tc.key}, tc.operator, tc.value, sb)
sb.Where(cond...)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)

View File

@@ -1372,9 +1372,6 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, fieldValueSel
instrumentationtypes.CodeFunctionName: "getRelatedValues",
})
// TODO(Tushar): thread orgID here to evaluate correctly
bodyJSONEnabled := t.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
// nothing to return as "related" value if there is nothing to filter on
if fieldValueSelector.ExistingQuery == "" {
return nil, true, nil
@@ -1424,7 +1421,6 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, fieldValueSel
FieldMapper: t.fm,
ConditionBuilder: t.conditionBuilder,
FieldKeys: keys,
BodyJSONEnabled: bodyJSONEnabled,
})
if err != nil {
t.logger.WarnContext(ctx, "error parsing existing query for related values", errors.Attr(err))
@@ -1450,22 +1446,22 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, fieldValueSel
// search on attributes
key.FieldContext = telemetrytypes.FieldContextAttribute
cond, err := t.conditionBuilder.ConditionFor(ctx, 0, 0, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb)
attrConds, _, err := t.conditionBuilder.ConditionFor(ctx, 0, 0, key, []*telemetrytypes.TelemetryFieldKey{key}, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb)
if err == nil {
conds = append(conds, cond)
conds = append(conds, attrConds...)
}
// search on resource
key.FieldContext = telemetrytypes.FieldContextResource
cond, err = t.conditionBuilder.ConditionFor(ctx, 0, 0, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb)
resourceConds, _, err := t.conditionBuilder.ConditionFor(ctx, 0, 0, key, []*telemetrytypes.TelemetryFieldKey{key}, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb)
if err == nil {
conds = append(conds, cond)
conds = append(conds, resourceConds...)
}
key.FieldContext = origContext
} else {
cond, err := t.conditionBuilder.ConditionFor(ctx, 0, 0, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb)
keyConds, _, err := t.conditionBuilder.ConditionFor(ctx, 0, 0, key, []*telemetrytypes.TelemetryFieldKey{key}, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb)
if err == nil {
conds = append(conds, cond)
conds = append(conds, keyConds...)
}
}

View File

@@ -142,6 +142,42 @@ func (c *conditionBuilder) conditionFor(
}
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
fieldKeysForName []*telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken are logs-body-only; reject for metrics.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}
keys, warning := querybuilder.ResolveKeys(key, fieldKeysForName)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
if len(keys) == 0 {
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
cond, err := c.conditionForKey(ctx, startNs, endNs, k, operator, value, sb)
if err != nil {
return nil, nil, err
}
conds = append(conds, cond)
}
return conds, warnings, nil
}
func (c *conditionBuilder) conditionForKey(
ctx context.Context,
startNs uint64,
endNs uint64,

View File

@@ -234,8 +234,8 @@ func TestConditionFor(t *testing.T) {
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.ConditionFor(ctx, 0, 0, &tc.key, tc.operator, tc.value, sb)
sb.Where(cond)
cond, _, err := conditionBuilder.ConditionFor(ctx, 0, 0, &tc.key, []*telemetrytypes.TelemetryFieldKey{&tc.key}, tc.operator, tc.value, sb)
sb.Where(cond...)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)
@@ -289,8 +289,8 @@ func TestConditionForMultipleKeys(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
var err error
for _, key := range tc.keys {
cond, err := conditionBuilder.ConditionFor(ctx, 0, 0, &key, tc.operator, tc.value, sb)
sb.Where(cond)
cond, _, err := conditionBuilder.ConditionFor(ctx, 0, 0, &key, []*telemetrytypes.TelemetryFieldKey{&key}, tc.operator, tc.value, sb)
sb.Where(cond...)
if err != nil {
t.Fatalf("Error getting condition for key %s: %v", key.Name, err)
}

View File

@@ -44,6 +44,46 @@ func keyIndexFilter(key *telemetrytypes.TelemetryFieldKey) any {
}
func (b *defaultConditionBuilder) ConditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
fieldKeysForName []*telemetrytypes.TelemetryFieldKey,
op qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken are logs-body-only functions; they never apply to the
// resource fingerprint table, so skip them (the main query still evaluates them).
if op.IsFunctionOperator() {
return nil, nil, nil
}
keys, warning := querybuilder.ResolveKeys(key, fieldKeysForName)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
// the resource fingerprint table only stores resource attributes; keys from
// any other context contribute no condition and are omitted. An empty result
// (including an unknown key) lets the caller skip this filter entirely.
if k.FieldContext != telemetrytypes.FieldContextResource {
continue
}
cond, err := b.conditionForKey(ctx, startNs, endNs, k, op, value, sb)
if err != nil {
return nil, nil, err
}
conds = append(conds, cond)
}
return conds, warnings, nil
}
func (b *defaultConditionBuilder) conditionForKey(
ctx context.Context,
startNs uint64,
endNs uint64,
@@ -53,10 +93,6 @@ func (b *defaultConditionBuilder) ConditionFor(
sb *sqlbuilder.SelectBuilder,
) (string, error) {
if key.FieldContext != telemetrytypes.FieldContextResource {
return querybuilder.SkipConditionLiteral, nil
}
// except for in, not in, between, not between all other operators should have formatted value
// as we store resource values as string
formattedValue := querybuilder.FormatValueForContains(value)

View File

@@ -205,8 +205,8 @@ func TestConditionBuilder(t *testing.T) {
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.ConditionFor(context.Background(), 0, 0, tc.key, tc.op, tc.value, sb)
sb.Where(cond)
cond, _, err := conditionBuilder.ConditionFor(context.Background(), 0, 0, tc.key, []*telemetrytypes.TelemetryFieldKey{tc.key}, tc.op, tc.value, sb)
sb.Where(cond...)
if tc.expectedErr != nil {
assert.Error(t, err)

View File

@@ -24,7 +24,6 @@ func NewResolver[T any](
source telemetrytypes.Source,
metadataStore telemetrytypes.MetadataStore,
fullTextColumn *telemetrytypes.TelemetryFieldKey,
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
fl flagger.Flagger,
telemetryStore telemetrystore.TelemetryStore,
threshold uint64,
@@ -38,7 +37,6 @@ func NewResolver[T any](
source,
metadataStore,
fullTextColumn,
jsonKeyToKey,
fl,
),
telemetryStore: telemetryStore,

View File

@@ -8,10 +8,8 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
)
@@ -28,7 +26,6 @@ type resourceFilterStatementBuilder[T any] struct {
flagger flagger.Flagger
fullTextColumn *telemetrytypes.TelemetryFieldKey
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
}
// Ensure interface compliance at compile time.
@@ -45,7 +42,6 @@ func New[T any](
source telemetrytypes.Source,
metadataStore telemetrytypes.MetadataStore,
fullTextColumn *telemetrytypes.TelemetryFieldKey,
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
fl flagger.Flagger,
) *resourceFilterStatementBuilder[T] {
set := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetryresourcefilter")
@@ -62,7 +58,6 @@ func New[T any](
source: source,
flagger: fl,
fullTextColumn: fullTextColumn,
jsonKeyToKey: jsonKeyToKey,
}
}
@@ -158,9 +153,6 @@ func (b *resourceFilterStatementBuilder[T]) addConditions(
variables map[string]qbtypes.VariableItem,
) (bool, error) {
// TODO(Tushar): thread orgID here to evaluate correctly
bodyJSONEnabled := b.flagger.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
// Add filter condition if present
if query.Filter != nil && query.Filter.Expression != "" {
@@ -171,16 +163,13 @@ func (b *resourceFilterStatementBuilder[T]) addConditions(
FieldMapper: b.fieldMapper,
ConditionBuilder: b.conditionBuilder,
FieldKeys: keys,
BodyJSONEnabled: bodyJSONEnabled,
FullTextColumn: b.fullTextColumn,
JsonKeyToKey: b.jsonKeyToKey,
SkipFullTextFilter: true,
SkipFunctionCalls: true,
// there is no need for "key" not found error for resource filtering
IgnoreNotFoundKeys: true,
Variables: variables,
StartNs: start,
EndNs: end,
// the resource-filter condition builder ignores keys it can't resolve (and
// skips function calls), so no "key not found" error arises here.
Variables: variables,
StartNs: start,
EndNs: end,
})
if err != nil {

View File

@@ -361,7 +361,6 @@ func TestResourceFilterStatementBuilder_Traces(t *testing.T) {
telemetrytypes.SourceUnspecified,
mockMetadataStore,
nil,
nil,
flaggertest.New(t),
)
@@ -555,7 +554,6 @@ func TestResourceFilterStatementBuilder_Logs(t *testing.T) {
telemetrytypes.SourceUnspecified,
mockMetadataStore,
nil,
nil,
flaggertest.New(t),
)
@@ -623,7 +621,6 @@ func TestResourceFilterStatementBuilder_Variables(t *testing.T) {
telemetrytypes.SourceUnspecified,
mockMetadataStore,
nil,
nil,
flaggertest.New(t),
)

View File

@@ -255,6 +255,42 @@ func (c *conditionBuilder) conditionFor(
}
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
fieldKeysForName []*telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken are logs-body-only; reject for traces.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}
keys, warning := querybuilder.ResolveKeys(key, fieldKeysForName)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
if len(keys) == 0 {
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
cond, err := c.conditionForKey(ctx, startNs, endNs, k, operator, value, sb)
if err != nil {
return nil, nil, err
}
conds = append(conds, cond)
}
return conds, warnings, nil
}
func (c *conditionBuilder) conditionForKey(
ctx context.Context,
startNs uint64,
endNs uint64,

View File

@@ -293,8 +293,8 @@ func TestConditionFor(t *testing.T) {
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.ConditionFor(ctx, 1761437108000000000, 1761458708000000000, &tc.key, tc.operator, tc.value, sb)
sb.Where(cond)
cond, _, err := conditionBuilder.ConditionFor(ctx, 1761437108000000000, 1761458708000000000, &tc.key, []*telemetrytypes.TelemetryFieldKey{&tc.key}, tc.operator, tc.value, sb)
sb.Where(cond...)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)
@@ -380,9 +380,9 @@ func TestConditionForResourceWithEvolution(t *testing.T) {
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.ConditionFor(ctx, tc.tsStart, tc.tsEnd, &tc.key, tc.operator, nil, sb)
cond, _, err := conditionBuilder.ConditionFor(ctx, tc.tsStart, tc.tsEnd, &tc.key, []*telemetrytypes.TelemetryFieldKey{&tc.key}, tc.operator, nil, sb)
require.NoError(t, err)
sb.Where(cond)
sb.Where(cond...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, tc.expectedSQL)
})

View File

@@ -54,7 +54,6 @@ func NewTraceQueryStatementBuilder(
telemetrytypes.SourceUnspecified,
metadataStore,
nil,
nil,
flagger,
telemetryStore,
skipResourceFingerprintThreshold,

View File

@@ -44,7 +44,6 @@ func NewTraceOperatorStatementBuilder(
telemetrytypes.SourceUnspecified,
metadataStore,
nil,
nil,
flagger,
)

View File

@@ -111,6 +111,13 @@ const (
FilterOperatorContains
FilterOperatorNotContains
// has/hasAny/hasAll are array membership functions; hasToken is a full-text token
// search (not array membership), so it is excluded from IsArrayFunctionOperator.
FilterOperatorHas
FilterOperatorHasToken
FilterOperatorHasAny
FilterOperatorHasAll
)
var operatorInverseMapping = map[FilterOperator]FilterOperator{
@@ -224,6 +231,45 @@ func (f FilterOperator) IsArrayOperator() bool {
}
}
// IsArrayFunctionOperator reports whether the operator is one of the array
// membership functions (has/hasAny/hasAll) that operate over array fields.
func (f FilterOperator) IsArrayFunctionOperator() bool {
switch f {
case FilterOperatorHas, FilterOperatorHasAny, FilterOperatorHasAll:
return true
default:
return false
}
}
// IsFunctionOperator reports whether the operator is a query function
// (has/hasAny/hasAll/hasToken); these apply to the logs body column only.
func (f FilterOperator) IsFunctionOperator() bool {
switch f {
case FilterOperatorHas, FilterOperatorHasAny, FilterOperatorHasAll, FilterOperatorHasToken:
return true
default:
return false
}
}
// FunctionName returns the query-text name of a function operator
// (has/hasAny/hasAll/hasToken), or "" for any non-function operator.
func (f FilterOperator) FunctionName() string {
switch f {
case FilterOperatorHas:
return "has"
case FilterOperatorHasAny:
return "hasAny"
case FilterOperatorHasAll:
return "hasAll"
case FilterOperatorHasToken:
return "hasToken"
default:
return ""
}
}
type OrderDirection struct {
valuer.String
}

View File

@@ -10,7 +10,7 @@ import (
)
var (
ErrColumnNotFound = errors.Newf(errors.TypeNotFound, errors.CodeNotFound, "field not found")
ErrColumnNotFound = errors.NewNotFoundf(errors.CodeNotFound, "field not found")
ErrBetweenValues = errors.NewInvalidInputf(errors.CodeInvalidInput, "(not) between operator requires two values")
ErrBetweenValuesType = errors.NewInvalidInputf(errors.CodeInvalidInput, "(not) between operator requires two values of the number type")
ErrInValues = errors.NewInvalidInputf(errors.CodeInvalidInput, "(not) in operator requires a list of values")
@@ -29,10 +29,14 @@ type FieldMapper interface {
ColumnExpressionFor(ctx context.Context, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, error)
}
// ConditionBuilder builds the condition for the filter.
// ConditionBuilder builds the conditions for the filter.
type ConditionBuilder interface {
// ConditionFor returns the condition for the given key, operator and value.
ConditionFor(ctx context.Context, startNs uint64, endNs uint64, key *telemetrytypes.TelemetryFieldKey, operator FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error)
// ConditionFor returns the conditions and any advisory warnings for a filter
// term. key is the field key as parsed from the query text; fieldKeysForName is
// the set of known field keys matching it (may be empty). The builder owns the
// decision of what to do — resolve ambiguity, fall back to a body JSON search,
// emit a "not found" error, or skip — and which errors/warnings are apt.
ConditionFor(ctx context.Context, startNs uint64, endNs uint64, key *telemetrytypes.TelemetryFieldKey, fieldKeysForName []*telemetrytypes.TelemetryFieldKey, operator FilterOperator, value any, sb *sqlbuilder.SelectBuilder) ([]string, []string, error)
}
type AggExprRewriter interface {

View File

@@ -172,58 +172,13 @@ func (f *TelemetryFieldKey) Normalize() {
}
// GetFieldKeyFromKeyText returns a TelemetryFieldKey from a key text.
// The key text is expected to be in the format of `fieldContext.fieldName:fieldDataType` in the search query.
// Both fieldContext and :fieldDataType are optional.
// fieldName can contain dots and can start with a dot (e.g., ".http_code").
// Special cases:
// - When key exactly matches a field context name (e.g., "body", "attribute"), use unspecified context.
// - When key starts with "body." prefix, use "body" as context with remainder as field name.
// GetFieldKeyFromKeyText returns a TelemetryFieldKey parsed from a key text of the
// form `fieldContext.fieldName:fieldDataType` (context and :dataType optional). It
// delegates to Normalize; see Normalize for the parsing rules and special cases.
func GetFieldKeyFromKeyText(key string) TelemetryFieldKey {
var explicitFieldDataType = FieldDataTypeUnspecified
var fieldName string
// Step 1: Parse data type from the right (after the last ":")
var keyWithoutDataType string
if colonIdx := strings.LastIndex(key, ":"); colonIdx != -1 {
potentialDataType := key[colonIdx+1:]
if dt, ok := fieldDataTypes[potentialDataType]; ok && dt != FieldDataTypeUnspecified {
explicitFieldDataType = dt
keyWithoutDataType = key[:colonIdx]
} else {
// No valid data type found, treat the entire key as the field name
keyWithoutDataType = key
}
} else {
keyWithoutDataType = key
}
// Step 2: Parse field context from the left
if dotIdx := strings.Index(keyWithoutDataType, "."); dotIdx != -1 {
potentialContext := keyWithoutDataType[:dotIdx]
if fc, ok := fieldContexts[potentialContext]; ok && fc != FieldContextUnspecified {
fieldName = keyWithoutDataType[dotIdx+1:]
// Step 2a: Handle special case for log.body.* fields
if fc == FieldContextLog && strings.HasPrefix(fieldName, BodyJSONStringSearchPrefix) {
fc = FieldContextBody
fieldName = strings.TrimPrefix(fieldName, BodyJSONStringSearchPrefix)
}
return TelemetryFieldKey{
Name: fieldName,
FieldContext: fc,
FieldDataType: explicitFieldDataType,
}
}
}
// Step 3: No context found, entire key is the field name
return TelemetryFieldKey{
Name: keyWithoutDataType,
FieldContext: FieldContextUnspecified,
FieldDataType: explicitFieldDataType,
}
f := TelemetryFieldKey{Name: key}
f.Normalize()
return f
}
func TelemetryFieldKeyToText(key *TelemetryFieldKey) string {