mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-10 23:10:47 +01:00
Compare commits
1 Commits
main
...
proto/logi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e30ef6a71 |
@@ -40,7 +40,10 @@ func (c *conditionBuilder) ConditionFor(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
keys, warning := querybuilder.ResolveKeys(key, querybuilder.MatchingFieldKeys(key, fieldKeys))
|
||||
// Rule state history fields have no family support, so every logical field
|
||||
// is single-member and flattens losslessly to its physical key.
|
||||
resolved, warning := querybuilder.ResolveLogicalFields(key, querybuilder.MatchingLogicalFields(key, fieldKeys))
|
||||
keys := querybuilder.SingleKeys(resolved)
|
||||
var warnings []string
|
||||
if warning != "" {
|
||||
warnings = append(warnings, warning)
|
||||
|
||||
@@ -64,6 +64,23 @@ func (m *fieldMapper) ColumnFor(ctx context.Context, _ valuer.UUID, _, _ uint64,
|
||||
return []*schema.Column{col}, nil
|
||||
}
|
||||
|
||||
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
|
||||
// Real columns always exist; labels are checked for key membership.
|
||||
func (m *fieldMapper) ExistsFor(ctx context.Context, _ valuer.UUID, _, _ uint64, key *telemetrytypes.TelemetryFieldKey, exists bool) (string, error) {
|
||||
col, err := m.getColumn(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if col.Name != "labels" || key.Name == "labels" {
|
||||
return "true", nil
|
||||
}
|
||||
pred := fmt.Sprintf("has(JSONExtractKeys(labels), '%s')", strings.ReplaceAll(key.Name, "'", "\\'"))
|
||||
if exists {
|
||||
return pred, nil
|
||||
}
|
||||
return "not " + pred, nil
|
||||
}
|
||||
|
||||
func (m *fieldMapper) ColumnExpressionFor(ctx context.Context, orgID valuer.UUID, tsStart, tsEnd uint64, field *telemetrytypes.TelemetryFieldKey, _ telemetrytypes.FieldDataType, _ map[string][]*telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
colName, err := m.FieldFor(ctx, orgID, tsStart, tsEnd, field)
|
||||
if err != nil {
|
||||
|
||||
43
pkg/querybuilder/family_selectors.go
Normal file
43
pkg/querybuilder/family_selectors.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// ExpandKeySelectorsForFamilies appends selectors for the other members of any
|
||||
// semantic-convention family a selector names, so the metadata fetched for a
|
||||
// query contains every spelling MatchingLogicalFields may group. It is the
|
||||
// resolution layer's prefetch: statement builders call it after deriving
|
||||
// selectors, and the metadata store stays family-blind (autocomplete responses
|
||||
// keep the literal spelling the user typed). Only trace selectors expand today,
|
||||
// matching family support; fuzzy (search-style) selectors never do.
|
||||
func ExpandKeySelectorsForFamilies(selectors []*telemetrytypes.FieldKeySelector) []*telemetrytypes.FieldKeySelector {
|
||||
out := selectors
|
||||
seen := make(map[string]bool, len(selectors))
|
||||
for _, selector := range selectors {
|
||||
seen[selector.Name] = true
|
||||
}
|
||||
|
||||
for _, selector := range selectors {
|
||||
if selector.Signal != telemetrytypes.SignalTraces ||
|
||||
selector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeFuzzy {
|
||||
continue
|
||||
}
|
||||
members := semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: selector.Name,
|
||||
Signal: selector.Signal,
|
||||
FieldContext: selector.FieldContext,
|
||||
})
|
||||
for _, member := range members {
|
||||
if seen[member] {
|
||||
continue
|
||||
}
|
||||
seen[member] = true
|
||||
expanded := *selector
|
||||
expanded.Name = member
|
||||
out = append(out, &expanded)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -21,24 +21,25 @@ const (
|
||||
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, ""
|
||||
// ResolveLogicalFields picks which logical fields a filter term builds conditions
|
||||
// for. With 0 or 1 field it returns the input unchanged and no warning. When a
|
||||
// name is ambiguous (several logical fields — a family is one field and never
|
||||
// ambiguous with itself) it returns a warning; a resource+attribute mix defaults
|
||||
// to the resource fields (the common intent), noted in the warning.
|
||||
func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields []*telemetrytypes.LogicalField) ([]*telemetrytypes.LogicalField, string) {
|
||||
if len(logicalFields) <= 1 {
|
||||
return logicalFields, ""
|
||||
}
|
||||
|
||||
warning := fmt.Sprintf(
|
||||
"Key `%s` is ambiguous, found %d different combinations of field context / data type: %v.",
|
||||
field.Name,
|
||||
len(fieldKeysForName),
|
||||
fieldKeysForName,
|
||||
len(logicalFields),
|
||||
logicalFields,
|
||||
)
|
||||
|
||||
hasResource, hasAttribute := false, false
|
||||
for _, item := range fieldKeysForName {
|
||||
for _, item := range logicalFields {
|
||||
switch item.FieldContext {
|
||||
case telemetrytypes.FieldContextResource:
|
||||
hasResource = true
|
||||
@@ -49,18 +50,40 @@ func ResolveKeys(field *telemetrytypes.TelemetryFieldKey, fieldKeysForName []*te
|
||||
|
||||
// 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 {
|
||||
filtered := make([]*telemetrytypes.LogicalField, 0, len(logicalFields))
|
||||
for _, item := range logicalFields {
|
||||
if item.FieldContext == telemetrytypes.FieldContextResource {
|
||||
filteredKeys = append(filteredKeys, item)
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
fieldKeysForName = filteredKeys
|
||||
logicalFields = filtered
|
||||
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
|
||||
return logicalFields, warning
|
||||
}
|
||||
|
||||
// WrapAsLogicalFields wraps physical keys (candidate or synthesized) as
|
||||
// single-member logical fields addressed by the requested spelling.
|
||||
func WrapAsLogicalFields(requestedName string, keys []*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.LogicalField {
|
||||
fields := make([]*telemetrytypes.LogicalField, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
fields = append(fields, telemetrytypes.SingleLogicalField(requestedName, key))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// SingleKeys flattens logical fields to their single members. It is the adapter
|
||||
// for signals whose fields are single-member by construction (every signal
|
||||
// without family support); a condition builder that uses it compiles per
|
||||
// physical key exactly as before.
|
||||
func SingleKeys(fields []*telemetrytypes.LogicalField) []*telemetrytypes.TelemetryFieldKey {
|
||||
keys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
keys = append(keys, field.Single())
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// NewKeyNotFoundError builds the error a condition builder returns when a filter term
|
||||
|
||||
94
pkg/querybuilder/logical_expr.go
Normal file
94
pkg/querybuilder/logical_expr.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// The two functions below are the only place family expressions are built.
|
||||
// They compose exclusively from the mapper's per-key primitives (FieldFor,
|
||||
// ExistsFor), so every member honors its own storage: materialized columns,
|
||||
// evolutions, and JSON plans ride the member keys, and a signal supports
|
||||
// families the moment its primitives are correct.
|
||||
|
||||
// LogicalValueExpr returns the value expression for a resolved logical field:
|
||||
// the member's own expression for a single-member field, and a current-first
|
||||
// merge across the members' expressions for a family.
|
||||
func LogicalValueExpr(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
tsStart, tsEnd uint64,
|
||||
fm qbtypes.FieldMapper,
|
||||
logical *telemetrytypes.LogicalField,
|
||||
) (string, error) {
|
||||
if !logical.IsFamily() {
|
||||
return fm.FieldFor(ctx, orgID, tsStart, tsEnd, logical.Single())
|
||||
}
|
||||
|
||||
memberExprs := make([]string, 0, len(logical.Members))
|
||||
for _, member := range logical.Members {
|
||||
expr, err := fm.FieldFor(ctx, orgID, tsStart, tsEnd, member)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
memberExprs = append(memberExprs, expr)
|
||||
}
|
||||
|
||||
if logical.FieldDataType == telemetrytypes.FieldDataTypeString {
|
||||
// The trailing '' keeps single-key semantics for rows without any
|
||||
// member: string maps read '' for an absent key, and negative
|
||||
// operators must keep including such rows (see AddDefaultExistsFilter).
|
||||
values := make([]string, 0, len(memberExprs))
|
||||
for _, expr := range memberExprs {
|
||||
values = append(values, fmt.Sprintf("NULLIF(%s, '')", expr))
|
||||
}
|
||||
return "COALESCE(" + strings.Join(values, ", ") + ", '')", nil
|
||||
}
|
||||
|
||||
// Numeric and boolean maps return zero for an absent key. If a family of
|
||||
// either type is enabled, this tail must become zero too.
|
||||
branches := make([]string, 0, len(logical.Members)*2)
|
||||
for i, member := range logical.Members {
|
||||
guard, err := fm.ExistsFor(ctx, orgID, tsStart, tsEnd, member, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
branches = append(branches, guard, memberExprs[i])
|
||||
}
|
||||
return "multiIf(" + strings.Join(branches, ", ") + ", NULL)", nil
|
||||
}
|
||||
|
||||
// LogicalExistsExpr returns the existence predicate for a resolved logical
|
||||
// field: the member's own predicate for a single-member field, presence of
|
||||
// any member for a family.
|
||||
func LogicalExistsExpr(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
tsStart, tsEnd uint64,
|
||||
fm qbtypes.FieldMapper,
|
||||
logical *telemetrytypes.LogicalField,
|
||||
exists bool,
|
||||
) (string, error) {
|
||||
if !logical.IsFamily() {
|
||||
return fm.ExistsFor(ctx, orgID, tsStart, tsEnd, logical.Single(), exists)
|
||||
}
|
||||
|
||||
guards := make([]string, 0, len(logical.Members))
|
||||
for _, member := range logical.Members {
|
||||
guard, err := fm.ExistsFor(ctx, orgID, tsStart, tsEnd, member, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
guards = append(guards, guard)
|
||||
}
|
||||
combined := "(" + strings.Join(guards, " OR ") + ")"
|
||||
if exists {
|
||||
return combined, nil
|
||||
}
|
||||
return "NOT " + combined, nil
|
||||
}
|
||||
95
pkg/querybuilder/logical_expr_test.go
Normal file
95
pkg/querybuilder/logical_expr_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stubFieldMapper provides just the two per-key primitives the shared
|
||||
// composition builds on; the remaining FieldMapper methods are unused here.
|
||||
type stubFieldMapper struct{}
|
||||
|
||||
func (stubFieldMapper) FieldFor(_ context.Context, _ valuer.UUID, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
return "value(" + key.Name + ")", nil
|
||||
}
|
||||
|
||||
func (stubFieldMapper) ExistsFor(_ context.Context, _ valuer.UUID, _, _ uint64, key *telemetrytypes.TelemetryFieldKey, exists bool) (string, error) {
|
||||
if exists {
|
||||
return "has(" + key.Name + ")", nil
|
||||
}
|
||||
return "NOT has(" + key.Name + ")", nil
|
||||
}
|
||||
|
||||
func (stubFieldMapper) ColumnFor(context.Context, valuer.UUID, uint64, uint64, *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
|
||||
return nil, qbtypes.ErrColumnNotFound
|
||||
}
|
||||
|
||||
func (stubFieldMapper) ColumnExpressionFor(context.Context, valuer.UUID, uint64, uint64, *telemetrytypes.TelemetryFieldKey, telemetrytypes.FieldDataType, map[string][]*telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
return "", qbtypes.ErrColumnNotFound
|
||||
}
|
||||
|
||||
func (stubFieldMapper) CandidateKeys(context.Context, valuer.UUID, *telemetrytypes.TelemetryFieldKey, any, map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringFamily(names ...string) *telemetrytypes.LogicalField {
|
||||
members := make([]*telemetrytypes.TelemetryFieldKey, 0, len(names))
|
||||
for _, name := range names {
|
||||
members = append(members, &telemetrytypes.TelemetryFieldKey{Name: name, FieldDataType: telemetrytypes.FieldDataTypeString})
|
||||
}
|
||||
return &telemetrytypes.LogicalField{Name: names[0], FieldDataType: telemetrytypes.FieldDataTypeString, Members: members}
|
||||
}
|
||||
|
||||
func TestLogicalValueExprSingleMemberDelegatesToFieldFor(t *testing.T) {
|
||||
logical := telemetrytypes.SingleLogicalField("a", &telemetrytypes.TelemetryFieldKey{Name: "a"})
|
||||
expr, err := LogicalValueExpr(context.Background(), valuer.UUID{}, 0, 0, stubFieldMapper{}, logical)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "value(a)", expr)
|
||||
}
|
||||
|
||||
func TestLogicalValueExprStringFamilyMergesCurrentFirst(t *testing.T) {
|
||||
expr, err := LogicalValueExpr(context.Background(), valuer.UUID{}, 0, 0, stubFieldMapper{}, stringFamily("current", "old"))
|
||||
require.NoError(t, err)
|
||||
// The trailing '' preserves keyless-row semantics for negative operators.
|
||||
assert.Equal(t, "COALESCE(NULLIF(value(current), ''), NULLIF(value(old), ''), '')", expr)
|
||||
}
|
||||
|
||||
func TestLogicalValueExprNumericFamilyGuardsEveryMember(t *testing.T) {
|
||||
logical := &telemetrytypes.LogicalField{
|
||||
Name: "current",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeNumber,
|
||||
Members: []*telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "current", FieldDataType: telemetrytypes.FieldDataTypeNumber},
|
||||
{Name: "old", FieldDataType: telemetrytypes.FieldDataTypeNumber},
|
||||
},
|
||||
}
|
||||
expr, err := LogicalValueExpr(context.Background(), valuer.UUID{}, 0, 0, stubFieldMapper{}, logical)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "multiIf(has(current), value(current), has(old), value(old), NULL)", expr)
|
||||
}
|
||||
|
||||
func TestLogicalExistsExprSingleMemberDelegatesToExistsFor(t *testing.T) {
|
||||
logical := telemetrytypes.SingleLogicalField("a", &telemetrytypes.TelemetryFieldKey{Name: "a"})
|
||||
expr, err := LogicalExistsExpr(context.Background(), valuer.UUID{}, 0, 0, stubFieldMapper{}, logical, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "NOT has(a)", expr)
|
||||
}
|
||||
|
||||
func TestLogicalExistsExprFamilyIsAnyMemberPresence(t *testing.T) {
|
||||
family := stringFamily("current", "old")
|
||||
|
||||
expr, err := LogicalExistsExpr(context.Background(), valuer.UUID{}, 0, 0, stubFieldMapper{}, family, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "(has(current) OR has(old))", expr)
|
||||
|
||||
expr, err = LogicalExistsExpr(context.Background(), valuer.UUID{}, 0, 0, stubFieldMapper{}, family, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "NOT (has(current) OR has(old))", expr)
|
||||
}
|
||||
165
pkg/querybuilder/logical_fields_test.go
Normal file
165
pkg/querybuilder/logical_fields_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func traceKey(name string, ctx telemetrytypes.FieldContext) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: ctx,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
}
|
||||
|
||||
func memberNames(logical *telemetrytypes.LogicalField) []string {
|
||||
names := make([]string, 0, len(logical.Members))
|
||||
for _, member := range logical.Members {
|
||||
names = append(names, member.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// The deployment.environment(.name) family (enabled in pkg/semconv) drives the
|
||||
// grouping tests below.
|
||||
|
||||
func TestMatchingLogicalFieldsGroupsFamilyMembers(t *testing.T) {
|
||||
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"deployment.environment.name": {traceKey("deployment.environment.name", telemetrytypes.FieldContextResource)},
|
||||
"deployment.environment": {traceKey("deployment.environment", telemetrytypes.FieldContextResource)},
|
||||
}
|
||||
|
||||
for _, requested := range []string{"deployment.environment.name", "deployment.environment"} {
|
||||
fields := MatchingLogicalFields(&telemetrytypes.TelemetryFieldKey{Name: requested}, fieldKeys)
|
||||
require.Len(t, fields, 1, "a family is one logical field, requested via %s", requested)
|
||||
logical := fields[0]
|
||||
assert.Equal(t, requested, logical.Name, "response identity is the requested spelling")
|
||||
assert.Equal(t, telemetrytypes.FieldContextResource, logical.FieldContext)
|
||||
assert.True(t, logical.IsFamily())
|
||||
assert.Equal(t, []string{"deployment.environment.name", "deployment.environment"}, memberNames(logical),
|
||||
"members are current-first regardless of the requested spelling")
|
||||
}
|
||||
}
|
||||
|
||||
// Member precedence is the family's current-first order, not lookup arrival
|
||||
// order: a current-name key found only under its context-prefixed spelling
|
||||
// arrives in the second lookup pass yet must still sort first.
|
||||
func TestMatchingLogicalFieldsOrdersMembersByFamilyRank(t *testing.T) {
|
||||
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"deployment.environment": {traceKey("deployment.environment", telemetrytypes.FieldContextResource)},
|
||||
"resource.deployment.environment.name": {traceKey("resource.deployment.environment.name", telemetrytypes.FieldContextResource)},
|
||||
}
|
||||
|
||||
fields := MatchingLogicalFields(&telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
}, fieldKeys)
|
||||
|
||||
require.Len(t, fields, 1)
|
||||
assert.Equal(t, []string{"resource.deployment.environment.name", "deployment.environment"}, memberNames(fields[0]))
|
||||
}
|
||||
|
||||
// Non-trace signals have no family support: the requested spelling stays
|
||||
// literal, and a family member name never pulls in its siblings.
|
||||
func TestMatchingLogicalFieldsKeepsLogsLiteral(t *testing.T) {
|
||||
logsKey := func(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
}
|
||||
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"deployment.environment.name": {logsKey("deployment.environment.name")},
|
||||
"deployment.environment": {logsKey("deployment.environment")},
|
||||
}
|
||||
|
||||
fields := MatchingLogicalFields(&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
|
||||
require.Len(t, fields, 1)
|
||||
assert.False(t, fields[0].IsFamily())
|
||||
assert.Equal(t, []string{"deployment.environment.name"}, memberNames(fields[0]))
|
||||
}
|
||||
|
||||
// A family and a genuine same-name collision stack cleanly: the family stays
|
||||
// one logical field, the collision adds another, and resource preference keeps
|
||||
// the family as a unit.
|
||||
func TestResolveLogicalFieldsKeepsFamilyThroughAmbiguity(t *testing.T) {
|
||||
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"deployment.environment.name": {
|
||||
traceKey("deployment.environment.name", telemetrytypes.FieldContextResource),
|
||||
traceKey("deployment.environment.name", telemetrytypes.FieldContextAttribute),
|
||||
},
|
||||
"deployment.environment": {traceKey("deployment.environment", telemetrytypes.FieldContextResource)},
|
||||
}
|
||||
|
||||
requested := &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}
|
||||
fields := MatchingLogicalFields(requested, fieldKeys)
|
||||
require.Len(t, fields, 2, "resource family + attribute collision")
|
||||
|
||||
resolved, warning := ResolveLogicalFields(requested, fields)
|
||||
assert.NotEmpty(t, warning)
|
||||
require.Len(t, resolved, 1)
|
||||
assert.Equal(t, telemetrytypes.FieldContextResource, resolved[0].FieldContext)
|
||||
assert.Equal(t, []string{"deployment.environment.name", "deployment.environment"}, memberNames(resolved[0]))
|
||||
}
|
||||
|
||||
// Members of a family with different data types never merge: the identity
|
||||
// (signal, context, data type) separates them into distinct logical fields.
|
||||
func TestMatchingLogicalFieldsNeverMergesAcrossDataTypes(t *testing.T) {
|
||||
numberKey := traceKey("deployment.environment", telemetrytypes.FieldContextResource)
|
||||
numberKey.FieldDataType = telemetrytypes.FieldDataTypeNumber
|
||||
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"deployment.environment.name": {traceKey("deployment.environment.name", telemetrytypes.FieldContextResource)},
|
||||
"deployment.environment": {numberKey},
|
||||
}
|
||||
|
||||
fields := MatchingLogicalFields(&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
|
||||
require.Len(t, fields, 2)
|
||||
for _, logical := range fields {
|
||||
assert.False(t, logical.IsFamily())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandKeySelectorsForFamilies(t *testing.T) {
|
||||
selectors := []*telemetrytypes.FieldKeySelector{
|
||||
{Name: "deployment.environment.name", Signal: telemetrytypes.SignalTraces, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact},
|
||||
{Name: "service.name", Signal: telemetrytypes.SignalTraces, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact},
|
||||
{Name: "deployment.environment.name", Signal: telemetrytypes.SignalLogs, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact},
|
||||
}
|
||||
|
||||
expanded := ExpandKeySelectorsForFamilies(selectors)
|
||||
|
||||
names := make([]string, 0, len(expanded))
|
||||
for _, selector := range expanded {
|
||||
names = append(names, selector.Name)
|
||||
}
|
||||
assert.Equal(t, []string{
|
||||
"deployment.environment.name",
|
||||
"service.name",
|
||||
"deployment.environment.name",
|
||||
"deployment.environment",
|
||||
}, names, "one sibling selector for the trace family member; logs and non-family names untouched")
|
||||
|
||||
sibling := expanded[len(expanded)-1]
|
||||
assert.Equal(t, telemetrytypes.SignalTraces, sibling.Signal)
|
||||
assert.Equal(t, telemetrytypes.FieldSelectorMatchTypeExact, sibling.SelectorMatchType)
|
||||
}
|
||||
|
||||
func TestExpandKeySelectorsForFamiliesDeduplicatesAndSkipsFuzzy(t *testing.T) {
|
||||
both := []*telemetrytypes.FieldKeySelector{
|
||||
{Name: "deployment.environment.name", Signal: telemetrytypes.SignalTraces, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact},
|
||||
{Name: "deployment.environment", Signal: telemetrytypes.SignalTraces, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact},
|
||||
}
|
||||
assert.Len(t, ExpandKeySelectorsForFamilies(both), 2, "both spellings already referenced")
|
||||
|
||||
fuzzy := []*telemetrytypes.FieldKeySelector{
|
||||
{Name: "deployment.environment.name", Signal: telemetrytypes.SignalTraces, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy},
|
||||
}
|
||||
assert.Len(t, ExpandKeySelectorsForFamilies(fuzzy), 1, "fuzzy (search-style) selectors never expand")
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -360,7 +361,7 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
|
||||
return ErrorConditionLiteral
|
||||
}
|
||||
}
|
||||
conds, ok := v.buildConditions(v.fullTextColumn, []*telemetrytypes.TelemetryFieldKey{v.fullTextColumn}, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(searchText))
|
||||
conds, ok := v.buildConditions(v.fullTextColumn, []*telemetrytypes.LogicalField{telemetrytypes.SingleLogicalField(v.fullTextColumn.Name, v.fullTextColumn)}, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(searchText))
|
||||
if !ok {
|
||||
return ErrorConditionLiteral
|
||||
}
|
||||
@@ -379,7 +380,7 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
|
||||
// VisitComparison handles all comparison operators.
|
||||
func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext) any {
|
||||
key := v.Visit(ctx.Key()).(*telemetrytypes.TelemetryFieldKey)
|
||||
matching := MatchingFieldKeys(key, v.fieldKeys)
|
||||
matching := MatchingLogicalFields(key, v.fieldKeys)
|
||||
|
||||
// Handle EXISTS specially
|
||||
if ctx.EXISTS() != nil {
|
||||
@@ -675,7 +676,7 @@ func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) an
|
||||
v.errors = append(v.errors, "full text search is not supported")
|
||||
return ErrorConditionLiteral
|
||||
}
|
||||
conds, ok := v.buildConditions(v.fullTextColumn, []*telemetrytypes.TelemetryFieldKey{v.fullTextColumn}, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text))
|
||||
conds, ok := v.buildConditions(v.fullTextColumn, []*telemetrytypes.LogicalField{telemetrytypes.SingleLogicalField(v.fullTextColumn.Name, v.fullTextColumn)}, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text))
|
||||
if !ok {
|
||||
return ErrorConditionLiteral
|
||||
}
|
||||
@@ -730,7 +731,7 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
|
||||
return ErrorConditionLiteral
|
||||
}
|
||||
|
||||
conds, ok := v.buildConditions(key, MatchingFieldKeys(key, v.fieldKeys), operator, value)
|
||||
conds, ok := v.buildConditions(key, MatchingLogicalFields(key, v.fieldKeys), operator, value)
|
||||
if !ok {
|
||||
return ErrorConditionLiteral
|
||||
}
|
||||
@@ -922,7 +923,7 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
|
||||
|
||||
// buildConditions invokes the condition builder for a filter term, folding its
|
||||
// warnings/errors into visitor state; returns false if an error was recorded.
|
||||
func (v *filterExpressionVisitor) buildConditions(key *telemetrytypes.TelemetryFieldKey, matching []*telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) ([]string, bool) {
|
||||
func (v *filterExpressionVisitor) buildConditions(key *telemetrytypes.TelemetryFieldKey, matching []*telemetrytypes.LogicalField, op qbtypes.FilterOperator, value any) ([]string, bool) {
|
||||
conds, warns, err := v.conditionBuilder.ConditionFor(v.context, v.orgID, v.startNs, v.endNs, key, v.fieldKeys, qbtypes.ConditionBuilderOptions{SkipResourceFilter: v.skipResourceFilter}, op, value, v.builder)
|
||||
if err != nil {
|
||||
_, _, _, _, errURL, _ := errors.Unwrapb(err)
|
||||
@@ -979,30 +980,123 @@ func assignIfEmpty(s *string, value string) {
|
||||
}
|
||||
}
|
||||
|
||||
// MatchingFieldKeys returns the field keys from the map that match the given key,
|
||||
// honoring any context/data type the user specified.
|
||||
func MatchingFieldKeys(field *telemetrytypes.TelemetryFieldKey, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey {
|
||||
fieldKeysForName := []*telemetrytypes.TelemetryFieldKey{}
|
||||
// familyMemberNames returns the physical spellings to look up for the
|
||||
// referenced key: the semantic-convention family members (current-first) when
|
||||
// the key can resolve to traces, else just the requested name. Only trace
|
||||
// field mappers understand families today; logs and metrics keep the
|
||||
// requested spelling until theirs land.
|
||||
func familyMemberNames(field *telemetrytypes.TelemetryFieldKey) []string {
|
||||
if field.Signal != telemetrytypes.SignalUnspecified && field.Signal != telemetrytypes.SignalTraces {
|
||||
return []string{field.Name}
|
||||
}
|
||||
return semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: field.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: field.FieldContext,
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// MatchingLogicalFields resolves the referenced key against the metadata map
|
||||
// into logical fields, honoring any context/data type the user specified.
|
||||
//
|
||||
// Physical keys that are members of one semantic-convention family (traces
|
||||
// only today) group into a single logical field per (signal, context, data
|
||||
// type) identity, members ordered current-first. Every other matching key
|
||||
// becomes its own single-member logical field. Ambiguity is therefore the
|
||||
// length of the returned slice, and a family is never ambiguous with itself.
|
||||
// Members alias the metadata map entries; nothing is copied or mutated.
|
||||
func MatchingLogicalFields(field *telemetrytypes.TelemetryFieldKey, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.LogicalField {
|
||||
members := familyMemberNames(field)
|
||||
memberRank := make(map[string]int, len(members))
|
||||
for i, member := range members {
|
||||
memberRank[member] = i
|
||||
}
|
||||
|
||||
// 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)
|
||||
fields := make([]*telemetrytypes.LogicalField, 0)
|
||||
indexByIdentity := make(map[string]int)
|
||||
// rank of the family member each physical key matched under; the stored
|
||||
// name of a context-prefixed match differs from the member name.
|
||||
ranks := make(map[*telemetrytypes.TelemetryFieldKey]int)
|
||||
|
||||
appendMatches := func(lookupName string, memberName string, contextAlreadyMatched bool) {
|
||||
for _, item := range fieldKeys[lookupName] {
|
||||
if !contextAlreadyMatched && field.FieldContext != telemetrytypes.FieldContextUnspecified && field.FieldContext != item.FieldContext {
|
||||
continue
|
||||
}
|
||||
if field.FieldDataType != telemetrytypes.FieldDataTypeUnspecified && field.FieldDataType != item.FieldDataType {
|
||||
continue
|
||||
}
|
||||
|
||||
// A member lookup may have found a same-named field in a scope where
|
||||
// this family does not apply. Keep exact names, but reject cross-member
|
||||
// matches outside the generated family scope.
|
||||
traceFamilyMatch := len(members) > 1 && item.Signal == telemetrytypes.SignalTraces
|
||||
if memberName != field.Name {
|
||||
if !traceFamilyMatch {
|
||||
continue
|
||||
}
|
||||
itemSelector := telemetrytypes.FieldKeySelector{
|
||||
Name: field.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: item.FieldContext,
|
||||
}
|
||||
if !slices.Contains(semconv.Members(semconv.KindAttribute, itemSelector), memberName) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !traceFamilyMatch {
|
||||
fields = append(fields, telemetrytypes.SingleLogicalField(field.Name, item))
|
||||
continue
|
||||
}
|
||||
|
||||
identity := item.Signal.StringValue() + ";" + item.FieldContext.StringValue() + ";" + item.FieldDataType.StringValue()
|
||||
index, found := indexByIdentity[identity]
|
||||
if !found {
|
||||
index = len(fields)
|
||||
indexByIdentity[identity] = index
|
||||
fields = append(fields, &telemetrytypes.LogicalField{
|
||||
Name: field.Name,
|
||||
Signal: item.Signal,
|
||||
FieldContext: item.FieldContext,
|
||||
FieldDataType: item.FieldDataType,
|
||||
})
|
||||
}
|
||||
logical := fields[index]
|
||||
duplicate := false
|
||||
for _, existing := range logical.Members {
|
||||
if existing.Name == item.Name {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !duplicate {
|
||||
ranks[item] = memberRank[memberName]
|
||||
logical.Members = append(logical.Members, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fieldKeysForName
|
||||
for _, member := range members {
|
||||
appendMatches(member, member, false)
|
||||
}
|
||||
|
||||
// A context may have been split off a name that legitimately contained it
|
||||
// (e.g. `attribute.key`); preserve that alternate reading for every family
|
||||
// member.
|
||||
if field.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
for _, member := range members {
|
||||
appendMatches(fmt.Sprintf("%s.%s", field.FieldContext.StringValue(), member), member, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Precedence is a property of the family, not of arrival order: members
|
||||
// sort current-first no matter which lookup pass found them.
|
||||
for _, logical := range fields {
|
||||
slices.SortStableFunc(logical.Members, func(a, b *telemetrytypes.TelemetryFieldKey) int {
|
||||
return ranks[a] - ranks[b]
|
||||
})
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -588,9 +588,11 @@ func TestVisitKey(t *testing.T) {
|
||||
|
||||
// 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)
|
||||
// builder behavior (error unless the key is ignored). The test maps carry
|
||||
// no signal, so every logical field is single-member and flattens losslessly.
|
||||
matching := MatchingLogicalFields(key, tt.fieldKeys)
|
||||
resolved, warning := ResolveLogicalFields(key, matching)
|
||||
keys := SingleKeys(resolved)
|
||||
|
||||
var gotErrors []string
|
||||
var gotMainErrURL, gotMainWrnURL string
|
||||
@@ -766,7 +768,8 @@ func (b *resourceConditionBuilder) ConditionFor(
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
keys, warning := ResolveKeys(key, MatchingFieldKeys(key, fieldKeys))
|
||||
resolved, warning := ResolveLogicalFields(key, MatchingLogicalFields(key, fieldKeys))
|
||||
keys := SingleKeys(resolved)
|
||||
var warnings []string
|
||||
if warning != "" {
|
||||
warnings = append(warnings, warning)
|
||||
@@ -808,7 +811,8 @@ func (b *conditionBuilder) ConditionFor(
|
||||
return []string{fmt.Sprintf("%s_cond", key.Name)}, nil, nil
|
||||
}
|
||||
|
||||
keys, warning := ResolveKeys(key, MatchingFieldKeys(key, fieldKeys))
|
||||
resolved, warning := ResolveLogicalFields(key, MatchingLogicalFields(key, fieldKeys))
|
||||
keys := SingleKeys(resolved)
|
||||
var warnings []string
|
||||
if warning != "" {
|
||||
warnings = append(warnings, warning)
|
||||
|
||||
@@ -44,6 +44,70 @@ func keyIndexFilter(key *telemetrytypes.TelemetryFieldKey) any {
|
||||
return fmt.Sprintf(`%%%s%%`, key.Name)
|
||||
}
|
||||
|
||||
// The three helpers below take the members of one logical field. With a single
|
||||
// member they render exactly the pre-family shapes; a family widens key/value
|
||||
// index hints to any-member and presence to any-member (all-absent when negated).
|
||||
|
||||
func keyIndexCondition(sb *sqlbuilder.SelectBuilder, column string, members []*telemetrytypes.TelemetryFieldKey) string {
|
||||
conditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
conditions = append(conditions, sb.Like(column, keyIndexFilter(member)))
|
||||
}
|
||||
if len(conditions) == 1 {
|
||||
return conditions[0]
|
||||
}
|
||||
return sb.Or(conditions...)
|
||||
}
|
||||
|
||||
func valueIndexCondition(
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
column string,
|
||||
members []*telemetrytypes.TelemetryFieldKey,
|
||||
op qbtypes.FilterOperator,
|
||||
value any,
|
||||
caseInsensitive bool,
|
||||
) string {
|
||||
conditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
patterns := valueForIndexFilter(op, member, value)
|
||||
switch values := patterns.(type) {
|
||||
case []string:
|
||||
for _, pattern := range values {
|
||||
conditions = append(conditions, sb.Like(column, pattern))
|
||||
}
|
||||
default:
|
||||
if caseInsensitive {
|
||||
conditions = append(conditions, sb.ILike(column, values))
|
||||
} else {
|
||||
conditions = append(conditions, sb.Like(column, values))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(conditions) == 1 {
|
||||
return conditions[0]
|
||||
}
|
||||
return sb.Or(conditions...)
|
||||
}
|
||||
|
||||
func memberPresenceCondition(sb *sqlbuilder.SelectBuilder, column string, members []*telemetrytypes.TelemetryFieldKey, exists bool) string {
|
||||
conditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
field := fmt.Sprintf("simpleJSONHas(%s, '%s')", column, member.Name)
|
||||
if exists {
|
||||
conditions = append(conditions, sb.E(field, true))
|
||||
} else {
|
||||
conditions = append(conditions, sb.NE(field, true))
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
if len(conditions) == 1 {
|
||||
return conditions[0]
|
||||
}
|
||||
return sb.Or(conditions...)
|
||||
}
|
||||
return sb.And(conditions...)
|
||||
}
|
||||
|
||||
// SkipResourceFilter is not applicable here: the fingerprint table only stores resource attributes.
|
||||
func (b *defaultConditionBuilder) ConditionFor(
|
||||
ctx context.Context,
|
||||
@@ -57,7 +121,7 @@ func (b *defaultConditionBuilder) ConditionFor(
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
) ([]string, []string, error) {
|
||||
matches := querybuilder.MatchingFieldKeys(key, fieldKeys)
|
||||
matches := querybuilder.MatchingLogicalFields(key, fieldKeys)
|
||||
|
||||
// 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).
|
||||
@@ -65,21 +129,21 @@ func (b *defaultConditionBuilder) ConditionFor(
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
keys, warning := querybuilder.ResolveKeys(key, matches)
|
||||
logicalFields, warning := querybuilder.ResolveLogicalFields(key, matches)
|
||||
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
|
||||
conds := make([]string, 0, len(logicalFields))
|
||||
for _, logical := range logicalFields {
|
||||
// the resource fingerprint table only stores resource attributes; fields 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 {
|
||||
if logical.FieldContext != telemetrytypes.FieldContextResource {
|
||||
continue
|
||||
}
|
||||
cond, err := b.conditionForKey(ctx, startNs, endNs, k, op, value, sb)
|
||||
cond, err := b.conditionForLogicalField(ctx, startNs, endNs, logical, op, value, sb)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -88,11 +152,11 @@ func (b *defaultConditionBuilder) ConditionFor(
|
||||
return conds, warnings, nil
|
||||
}
|
||||
|
||||
func (b *defaultConditionBuilder) conditionForKey(
|
||||
func (b *defaultConditionBuilder) conditionForLogicalField(
|
||||
ctx context.Context,
|
||||
startNs uint64,
|
||||
endNs uint64,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
logical *telemetrytypes.LogicalField,
|
||||
op qbtypes.FilterOperator,
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
@@ -102,7 +166,7 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
// as we store resource values as string
|
||||
formattedValue := querybuilder.FormatValueForContains(value)
|
||||
|
||||
columns, err := b.fm.ColumnFor(ctx, valuer.UUID{}, startNs, endNs, key)
|
||||
columns, err := b.fm.ColumnFor(ctx, valuer.UUID{}, startNs, endNs, logical.Single())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -115,10 +179,12 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
// as we have not changed the resource column in the resource fingerprint table.
|
||||
column := columns[0]
|
||||
|
||||
keyIdxFilter := sb.Like(column.Name, keyIndexFilter(key))
|
||||
valueForIndexFilter := valueForIndexFilter(op, key, value)
|
||||
members := logical.Members
|
||||
isFamily := logical.IsFamily()
|
||||
keyIdxFilter := keyIndexCondition(sb, column.Name, members)
|
||||
singleValueIndexFilter := valueForIndexFilter(op, members[0], value)
|
||||
|
||||
fieldName, err := b.fm.FieldFor(ctx, valuer.UUID{}, startNs, endNs, key)
|
||||
fieldName, err := querybuilder.LogicalValueExpr(ctx, valuer.UUID{}, startNs, endNs, b.fm, logical)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -128,12 +194,17 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
return sb.And(
|
||||
sb.E(fieldName, formattedValue),
|
||||
keyIdxFilter,
|
||||
sb.Like(column.Name, valueForIndexFilter),
|
||||
valueIndexCondition(sb, column.Name, members, op, value, false),
|
||||
), nil
|
||||
case qbtypes.FilterOperatorNotEqual:
|
||||
if isFamily {
|
||||
// A negated value-index hint would drop rows where another member
|
||||
// holds the value; the fingerprint scan is small enough without it.
|
||||
return sb.NE(fieldName, formattedValue), nil
|
||||
}
|
||||
return sb.And(
|
||||
sb.NE(fieldName, formattedValue),
|
||||
sb.NotLike(column.Name, valueForIndexFilter),
|
||||
sb.NotLike(column.Name, singleValueIndexFilter),
|
||||
), nil
|
||||
case qbtypes.FilterOperatorGreaterThan:
|
||||
return sb.And(sb.GT(fieldName, formattedValue), keyIdxFilter), nil
|
||||
@@ -148,7 +219,7 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
return sb.And(
|
||||
sb.ILike(fieldName, formattedValue),
|
||||
keyIdxFilter,
|
||||
sb.ILike(column.Name, valueForIndexFilter),
|
||||
valueIndexCondition(sb, column.Name, members, op, value, true),
|
||||
), nil
|
||||
case qbtypes.FilterOperatorNotLike, qbtypes.FilterOperatorNotILike:
|
||||
// no index filter: as cannot apply `not contains x%y` as y can be somewhere else
|
||||
@@ -185,13 +256,11 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
inConditions = append(inConditions, sb.E(fieldName, querybuilder.FormatValueForContains(v)))
|
||||
}
|
||||
mainCondition := sb.Or(inConditions...)
|
||||
valConditions := make([]string, 0, len(values))
|
||||
if valuesForIndexFilter, ok := valueForIndexFilter.([]string); ok {
|
||||
for _, v := range valuesForIndexFilter {
|
||||
valConditions = append(valConditions, sb.Like(column.Name, v))
|
||||
}
|
||||
}
|
||||
mainCondition = sb.And(mainCondition, keyIdxFilter, sb.Or(valConditions...))
|
||||
mainCondition = sb.And(
|
||||
mainCondition,
|
||||
keyIdxFilter,
|
||||
valueIndexCondition(sb, column.Name, members, op, value, false),
|
||||
)
|
||||
|
||||
return mainCondition, nil
|
||||
case qbtypes.FilterOperatorNotIn:
|
||||
@@ -204,8 +273,13 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
notInConditions = append(notInConditions, sb.NE(fieldName, querybuilder.FormatValueForContains(v)))
|
||||
}
|
||||
mainCondition := sb.And(notInConditions...)
|
||||
if isFamily {
|
||||
// A negated value-index hint would drop rows where another member
|
||||
// holds the value; the fingerprint scan is small enough without it.
|
||||
return mainCondition, nil
|
||||
}
|
||||
valConditions := make([]string, 0, len(values))
|
||||
if valuesForIndexFilter, ok := valueForIndexFilter.([]string); ok {
|
||||
if valuesForIndexFilter, ok := singleValueIndexFilter.([]string); ok {
|
||||
for _, v := range valuesForIndexFilter {
|
||||
valConditions = append(valConditions, sb.NotLike(column.Name, v))
|
||||
}
|
||||
@@ -215,13 +289,11 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
|
||||
case qbtypes.FilterOperatorExists:
|
||||
return sb.And(
|
||||
sb.E(fmt.Sprintf("simpleJSONHas(%s, '%s')", column.Name, key.Name), true),
|
||||
memberPresenceCondition(sb, column.Name, members, true),
|
||||
keyIdxFilter,
|
||||
), nil
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
return sb.And(
|
||||
sb.NE(fmt.Sprintf("simpleJSONHas(%s, '%s')", column.Name, key.Name), true),
|
||||
), nil
|
||||
return memberPresenceCondition(sb, column.Name, members, false), nil
|
||||
|
||||
case qbtypes.FilterOperatorRegexp:
|
||||
return sb.And(
|
||||
@@ -237,7 +309,7 @@ func (b *defaultConditionBuilder) conditionForKey(
|
||||
return sb.And(
|
||||
sb.ILike(fieldName, fmt.Sprintf(`%%%s%%`, formattedValue)),
|
||||
keyIdxFilter,
|
||||
sb.ILike(column.Name, valueForIndexFilter),
|
||||
valueIndexCondition(sb, column.Name, members, op, value, true),
|
||||
), nil
|
||||
case qbtypes.FilterOperatorNotContains:
|
||||
// no index filter: as cannot apply `not contains x%y` as y can be somewhere else
|
||||
|
||||
90
pkg/statementbuilder/resourcefilter/family_test.go
Normal file
90
pkg/statementbuilder/resourcefilter/family_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package resourcefilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
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"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func familyFieldKeys() map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
newKey := func(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
}
|
||||
return map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"deployment.environment.name": {newKey("deployment.environment.name")},
|
||||
"deployment.environment": {newKey("deployment.environment")},
|
||||
}
|
||||
}
|
||||
|
||||
func familyConditionSQL(t *testing.T, op qbtypes.FilterOperator, value any) (string, []any) {
|
||||
t.Helper()
|
||||
cb := NewConditionBuilder(NewFieldMapper())
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conds, _, err := cb.ConditionFor(context.Background(), valuer.UUID{}, 0, 0,
|
||||
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"},
|
||||
familyFieldKeys(), qbtypes.ConditionBuilderOptions{}, op, value, sb)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, conds, 1)
|
||||
sb.Where(conds...)
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
|
||||
const familyValueExpr = "COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '')"
|
||||
|
||||
func TestFamilyEqualWidensIndexHintsToAnyMember(t *testing.T) {
|
||||
sql, args := familyConditionSQL(t, qbtypes.FilterOperatorEqual, "production")
|
||||
assert.Contains(t, sql, familyValueExpr+" = ?")
|
||||
// key hint: either member name may appear in the labels JSON
|
||||
assert.Contains(t, sql, "(labels LIKE ? OR labels LIKE ?)")
|
||||
assert.Contains(t, args, "%deployment.environment.name%")
|
||||
assert.Contains(t, args, "%deployment.environment%")
|
||||
assert.Contains(t, args, `%deployment.environment.name":"production%`)
|
||||
assert.Contains(t, args, `%deployment.environment":"production%`)
|
||||
}
|
||||
|
||||
func TestFamilyNotEqualDropsNegatedValueHint(t *testing.T) {
|
||||
sql, args := familyConditionSQL(t, qbtypes.FilterOperatorNotEqual, "production")
|
||||
assert.Contains(t, sql, familyValueExpr+" <> ?")
|
||||
// A negated per-member value hint would drop rows where the other member
|
||||
// holds the value, so the family form carries no index hints at all.
|
||||
assert.NotContains(t, sql, "NOT LIKE")
|
||||
assert.Equal(t, []any{"production"}, args)
|
||||
}
|
||||
|
||||
func TestFamilyExistsIsAnyMemberPresence(t *testing.T) {
|
||||
sql, _ := familyConditionSQL(t, qbtypes.FilterOperatorExists, nil)
|
||||
assert.Contains(t, sql, "(simpleJSONHas(labels, 'deployment.environment.name') = ? OR simpleJSONHas(labels, 'deployment.environment') = ?)")
|
||||
|
||||
sql, _ = familyConditionSQL(t, qbtypes.FilterOperatorNotExists, nil)
|
||||
assert.Contains(t, sql, "(simpleJSONHas(labels, 'deployment.environment.name') <> ? AND simpleJSONHas(labels, 'deployment.environment') <> ?)")
|
||||
}
|
||||
|
||||
// With only one member in metadata the SQL keeps the exact pre-family shape,
|
||||
// including the negated value hint on !=.
|
||||
func TestSingleMemberShapesUnchanged(t *testing.T) {
|
||||
cb := NewConditionBuilder(NewFieldMapper())
|
||||
soloKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"deployment.environment.name": familyFieldKeys()["deployment.environment.name"],
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conds, _, err := cb.ConditionFor(context.Background(), valuer.UUID{}, 0, 0,
|
||||
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"},
|
||||
soloKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotEqual, "production", sb)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conds...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Contains(t, sql, "simpleJSONExtractString(labels, 'deployment.environment.name') <> ?")
|
||||
assert.Contains(t, sql, "labels NOT LIKE ?")
|
||||
assert.NotContains(t, sql, "COALESCE")
|
||||
}
|
||||
@@ -71,6 +71,33 @@ func (m *defaultFieldMapper) FieldFor(
|
||||
return columns[0].Name, nil
|
||||
}
|
||||
|
||||
// ExistsFor reports key presence in the fingerprint labels JSON. Only resource
|
||||
// context keys have a presence notion here; anything else is a real column and
|
||||
// always present.
|
||||
func (m *defaultFieldMapper) ExistsFor(
|
||||
ctx context.Context,
|
||||
_ valuer.UUID,
|
||||
tsStart, tsEnd uint64,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
exists bool,
|
||||
) (string, error) {
|
||||
columns, err := m.getColumn(ctx, tsStart, tsEnd, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource {
|
||||
if exists {
|
||||
return "true", nil
|
||||
}
|
||||
return "false", nil
|
||||
}
|
||||
pred := fmt.Sprintf("simpleJSONHas(%s, '%s')", columns[0].Name, key.Name)
|
||||
if exists {
|
||||
return pred, nil
|
||||
}
|
||||
return "NOT " + pred, nil
|
||||
}
|
||||
|
||||
func (m *defaultFieldMapper) ColumnExpressionFor(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
|
||||
@@ -99,7 +99,7 @@ func (b *resourceFilterStatementBuilder[T]) Build(
|
||||
q.Select("fingerprint")
|
||||
q.From(fmt.Sprintf("%s.%s", b.dbName, b.tableName))
|
||||
|
||||
keySelectors := b.getKeySelectors(query)
|
||||
keySelectors := querybuilder.ExpandKeySelectorsForFamilies(b.getKeySelectors(query))
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -266,7 +266,7 @@ func (b *scopedTraceStatementBuilder) fetchKeys(ctx context.Context, orgID value
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
})
|
||||
}
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, selectors)
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(selectors))
|
||||
return keys, err
|
||||
}
|
||||
|
||||
@@ -442,7 +442,7 @@ func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context,
|
||||
for i := range selectors {
|
||||
selectors[i].Signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, selectors)
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(selectors))
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ func (b *traceQueryStatementBuilder) Build(
|
||||
|
||||
// We modify SelectFields above (injecting default fields), and those default
|
||||
// fields can carry keys that need evolutions, so fetch keys after that.
|
||||
keySelectors := getKeySelectors(query)
|
||||
keySelectors := querybuilder.ExpandKeySelectorsForFamilies(getKeySelectors(query))
|
||||
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, keySelectors)
|
||||
if err != nil {
|
||||
|
||||
@@ -75,6 +75,27 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "family filter merges both spellings",
|
||||
requestType: qbtypes.RequestTypeScalar,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "deployment.environment.name = 'production'",
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') = ? AND (labels LIKE ? OR labels LIKE ?) AND (labels LIKE ? OR labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
|
||||
Args: []any{"production", "%deployment.environment.name%", "%deployment.environment%", "%deployment.environment.name\":\"production%", "%deployment.environment\":\"production%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "OR b/w resource attr and attribute",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
|
||||
@@ -212,7 +212,7 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
|
||||
return cteName, nil
|
||||
}
|
||||
|
||||
keySelectors := getKeySelectors(*query)
|
||||
keySelectors := querybuilder.ExpandKeySelectorsForFamilies(getKeySelectors(*query))
|
||||
b.stmtBuilder.logger.DebugContext(ctx, "Key selectors for query", slog.String("query_name", queryName), slog.Any("key_selectors", keySelectors))
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, b.orgID, keySelectors)
|
||||
if err != nil {
|
||||
@@ -442,7 +442,7 @@ func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFro
|
||||
}
|
||||
}
|
||||
|
||||
keySelectors := b.getKeySelectors()
|
||||
keySelectors := querybuilder.ExpandKeySelectorsForFamilies(b.getKeySelectors())
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, b.orgID, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -38,8 +38,11 @@ func (c *conditionBuilder) ConditionFor(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// an unknown key simply yields no condition rather than an error.
|
||||
keys, warning := querybuilder.ResolveKeys(key, querybuilder.MatchingFieldKeys(key, fieldKeys))
|
||||
// an unknown key simply yields no condition rather than an error. Metadata
|
||||
// fields have no family support, so every logical field is single-member
|
||||
// and flattens losslessly to its physical key.
|
||||
resolved, warning := querybuilder.ResolveLogicalFields(key, querybuilder.MatchingLogicalFields(key, fieldKeys))
|
||||
keys := querybuilder.SingleKeys(resolved)
|
||||
var warnings []string
|
||||
if warning != "" {
|
||||
warnings = append(warnings, warning)
|
||||
|
||||
@@ -58,6 +58,19 @@ func (m *fieldMapper) ColumnFor(ctx context.Context, _ valuer.UUID, tsStart, tsE
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
|
||||
func (m *fieldMapper) ExistsFor(ctx context.Context, _ valuer.UUID, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey, exists bool) (string, error) {
|
||||
columns, err := m.getColumn(ctx, tsStart, tsEnd, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pred := fmt.Sprintf("mapContains(%s, '%s')", columns[0].Name, key.Name)
|
||||
if exists {
|
||||
return pred, nil
|
||||
}
|
||||
return "NOT " + pred, nil
|
||||
}
|
||||
|
||||
func (m *fieldMapper) FieldFor(ctx context.Context, _ valuer.UUID, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
columns, err := m.getColumn(ctx, startNs, endNs, key)
|
||||
if err != nil {
|
||||
|
||||
@@ -139,7 +139,10 @@ func (c *conditionBuilder) ConditionFor(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
keys, warning := querybuilder.ResolveKeys(key, querybuilder.MatchingFieldKeys(key, fieldKeys))
|
||||
// Audit fields have no family support, so every logical field is
|
||||
// single-member and flattens losslessly to its physical key.
|
||||
resolved, warning := querybuilder.ResolveLogicalFields(key, querybuilder.MatchingLogicalFields(key, fieldKeys))
|
||||
keys := querybuilder.SingleKeys(resolved)
|
||||
var warnings []string
|
||||
if warning != "" {
|
||||
warnings = append(warnings, warning)
|
||||
|
||||
@@ -97,6 +97,19 @@ func (m *fieldMapper) ColumnFor(ctx context.Context, _ valuer.UUID, _, _ uint64,
|
||||
return m.getColumn(ctx, key)
|
||||
}
|
||||
|
||||
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
|
||||
func (m *fieldMapper) ExistsFor(ctx context.Context, orgID valuer.UUID, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey, exists bool) (string, error) {
|
||||
fieldExpression, err := m.FieldFor(ctx, orgID, tsStart, tsEnd, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
columns, err := m.getColumn(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return querybuilder.ExistsExpression(columns, key, tsStart, tsEnd, fieldExpression, exists)
|
||||
}
|
||||
|
||||
func (m *fieldMapper) ColumnExpressionFor(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
|
||||
@@ -452,7 +452,7 @@ func (c *conditionBuilder) ConditionFor(
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
) ([]string, []string, error) {
|
||||
matches := querybuilder.MatchingFieldKeys(key, fieldKeys)
|
||||
matches := querybuilder.MatchingLogicalFields(key, fieldKeys)
|
||||
skipResourceFilter := options.SkipResourceFilter
|
||||
|
||||
// search() resolves its own (optional) scope; handle it before key resolution.
|
||||
@@ -460,7 +460,10 @@ func (c *conditionBuilder) ConditionFor(
|
||||
return c.conditionForSearch(ctx, orgID, key, value, sb)
|
||||
}
|
||||
|
||||
keys, warning := querybuilder.ResolveKeys(key, matches)
|
||||
// Logs fields have no family support yet, so every logical field is
|
||||
// single-member and flattens losslessly to its physical key.
|
||||
resolved, warning := querybuilder.ResolveLogicalFields(key, matches)
|
||||
keys := querybuilder.SingleKeys(resolved)
|
||||
var warnings []string
|
||||
if warning != "" {
|
||||
warnings = append(warnings, warning)
|
||||
|
||||
@@ -279,7 +279,7 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
}
|
||||
var stmts []string
|
||||
for _, key := range candidates {
|
||||
guard, err := m.existsExpressionFor(ctx, orgID, tsStart, tsEnd, key, true)
|
||||
guard, err := m.ExistsFor(ctx, orgID, tsStart, tsEnd, key, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -308,7 +308,7 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
if !m.membershipGuarded(ctx, orgID, tsStart, tsEnd, candidates[0]) {
|
||||
return m.FieldFor(ctx, orgID, tsStart, tsEnd, candidates[0])
|
||||
}
|
||||
guard, err := m.existsExpressionFor(ctx, orgID, tsStart, tsEnd, candidates[0], true)
|
||||
guard, err := m.ExistsFor(ctx, orgID, tsStart, tsEnd, candidates[0], true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -326,7 +326,7 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
|
||||
var stmts []string
|
||||
for _, key := range candidates {
|
||||
guard, err := m.existsExpressionFor(ctx, orgID, tsStart, tsEnd, key, true)
|
||||
guard, err := m.ExistsFor(ctx, orgID, tsStart, tsEnd, key, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -569,7 +569,8 @@ func (m *fieldMapper) membershipGuarded(ctx context.Context, orgID valuer.UUID,
|
||||
return columnType == schema.ColumnTypeEnumMap || columnType == schema.ColumnTypeEnumJSON
|
||||
}
|
||||
|
||||
func (m *fieldMapper) existsExpressionFor(
|
||||
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
|
||||
func (m *fieldMapper) ExistsFor(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
tsStart, tsEnd uint64,
|
||||
|
||||
@@ -162,7 +162,9 @@ func (c *conditionBuilder) ConditionFor(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
keys := querybuilder.MatchingFieldKeys(key, fieldKeys)
|
||||
// Metric labels have no family support, so every logical field is
|
||||
// single-member and flattens losslessly to its physical key.
|
||||
keys := querybuilder.SingleKeys(querybuilder.MatchingLogicalFields(key, fieldKeys))
|
||||
var warnings []string
|
||||
if len(keys) == 0 {
|
||||
if _, isColumn := timeSeriesV4Columns[key.Name]; isColumn {
|
||||
|
||||
@@ -97,6 +97,18 @@ func (m *fieldMapper) ColumnFor(ctx context.Context, _ valuer.UUID, tsStart, tsE
|
||||
return m.getColumn(ctx, tsStart, tsEnd, key)
|
||||
}
|
||||
|
||||
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
|
||||
// Intrinsic fields always exist; labels are checked for key membership.
|
||||
func (m *fieldMapper) ExistsFor(_ context.Context, _ valuer.UUID, _, _ uint64, key *telemetrytypes.TelemetryFieldKey, exists bool) (string, error) {
|
||||
if slices.Contains(IntrinsicFields, key.Name) {
|
||||
return "true", nil
|
||||
}
|
||||
if exists {
|
||||
return fmt.Sprintf("has(JSONExtractKeys(labels), '%s')", key.Name), nil
|
||||
}
|
||||
return fmt.Sprintf("not has(JSONExtractKeys(labels), '%s')", key.Name), nil
|
||||
}
|
||||
|
||||
func (m *fieldMapper) ColumnExpressionFor(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
|
||||
@@ -32,7 +32,7 @@ func (c *conditionBuilder) conditionFor(
|
||||
orgID valuer.UUID,
|
||||
startNs uint64,
|
||||
endNs uint64,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
logical *telemetrytypes.LogicalField,
|
||||
operator qbtypes.FilterOperator,
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
@@ -42,13 +42,13 @@ func (c *conditionBuilder) conditionFor(
|
||||
value = querybuilder.FormatValueForContains(value)
|
||||
}
|
||||
|
||||
fieldExpression, err := c.fm.FieldFor(ctx, orgID, startNs, endNs, key)
|
||||
fieldExpression, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, c.fm, logical)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// TODO(srikanthccv): maybe extend this to every possible attribute
|
||||
if key.Name == "duration_nano" || key.Name == "durationNano" { // QoL improvement
|
||||
if logical.Name == "duration_nano" || logical.Name == "durationNano" { // QoL improvement
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if duration, err := time.ParseDuration(v); err == nil {
|
||||
@@ -65,7 +65,7 @@ func (c *conditionBuilder) conditionFor(
|
||||
}
|
||||
}
|
||||
|
||||
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
|
||||
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(logical.Single(), value, fieldExpression, operator)
|
||||
|
||||
// regular operators
|
||||
switch operator {
|
||||
@@ -154,11 +154,7 @@ func (c *conditionBuilder) conditionFor(
|
||||
// in the query builder, `exists` and `not exists` are used for
|
||||
// key membership checks, so depending on the column type, the condition changes
|
||||
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
|
||||
columns, err := c.fm.ColumnFor(ctx, orgID, startNs, endNs, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pred, err := querybuilder.ExistsExpression(columns, key, startNs, endNs, fieldExpression, operator == qbtypes.FilterOperatorExists)
|
||||
pred, err := querybuilder.LogicalExistsExpr(ctx, orgID, startNs, endNs, c.fm, logical, operator == qbtypes.FilterOperatorExists)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -210,10 +206,10 @@ func (c *conditionBuilder) ConditionFor(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
matches := querybuilder.MatchingFieldKeys(key, fieldKeys)
|
||||
matches := querybuilder.MatchingLogicalFields(key, fieldKeys)
|
||||
skipResourceFilter := options.SkipResourceFilter
|
||||
|
||||
keys, warning := querybuilder.ResolveKeys(key, matches)
|
||||
logicalFields, warning := querybuilder.ResolveLogicalFields(key, matches)
|
||||
var warnings []string
|
||||
if warning != "" {
|
||||
warnings = append(warnings, warning)
|
||||
@@ -221,10 +217,10 @@ func (c *conditionBuilder) ConditionFor(
|
||||
// A bare key that names a real column filters on the column too — first. When metadata
|
||||
// only knows the name under other contexts, prepend the column and keep metadata matches
|
||||
// only where their type is consistent with it (a corrupt entry can't degrade the column).
|
||||
if key.FieldContext == telemetrytypes.FieldContextUnspecified && len(keys) > 0 {
|
||||
if key.FieldContext == telemetrytypes.FieldContextUnspecified && len(logicalFields) > 0 {
|
||||
hasColumn := false
|
||||
for _, k := range keys {
|
||||
if k.FieldContext == telemetrytypes.FieldContextSpan {
|
||||
for _, logical := range logicalFields {
|
||||
if logical.FieldContext == telemetrytypes.FieldContextSpan {
|
||||
hasColumn = true
|
||||
break
|
||||
}
|
||||
@@ -232,49 +228,49 @@ func (c *conditionBuilder) ConditionFor(
|
||||
if !hasColumn {
|
||||
probe := telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextSpan, key.FieldDataType)
|
||||
if cols, colErr := c.fm.ColumnFor(ctx, orgID, startNs, endNs, probe); colErr == nil && len(cols) > 0 {
|
||||
combined := make([]*telemetrytypes.TelemetryFieldKey, 0, len(keys)+1)
|
||||
combined = append(combined, probe)
|
||||
for _, k := range keys {
|
||||
if columnMatchesDataType(cols[0], k.FieldDataType) {
|
||||
combined = append(combined, k)
|
||||
combined := make([]*telemetrytypes.LogicalField, 0, len(logicalFields)+1)
|
||||
combined = append(combined, telemetrytypes.SingleLogicalField(key.Name, probe))
|
||||
for _, logical := range logicalFields {
|
||||
if columnMatchesDataType(cols[0], logical.FieldDataType) {
|
||||
combined = append(combined, logical)
|
||||
}
|
||||
}
|
||||
keys = combined
|
||||
logicalFields = combined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
synthesized := false
|
||||
if len(keys) == 0 {
|
||||
if len(logicalFields) == 0 {
|
||||
// Not in metadata. CandidateKeys resolves it: fold contexts (span/trace) get the
|
||||
// metadata map so it can honor a real column, correct to a stripped-name metadata
|
||||
// match, or synthesize; strict contexts pass nil and keep their synthesize path.
|
||||
keys = c.fm.CandidateKeys(ctx, orgID, key, value, candidateLookupKeys(key, fieldKeys))
|
||||
if len(keys) == 0 {
|
||||
logicalFields = querybuilder.WrapAsLogicalFields(key.Name, c.fm.CandidateKeys(ctx, orgID, key, value, candidateLookupKeys(key, fieldKeys)))
|
||||
if len(logicalFields) == 0 {
|
||||
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
|
||||
}
|
||||
synthesized = true
|
||||
warnings = append(warnings, querybuilder.NewKeyNotFoundWarning(key.Name))
|
||||
}
|
||||
|
||||
// When a resource sub-query already covers the term, drop resource keys from the main
|
||||
// When a resource sub-query already covers the term, drop resource fields from the main
|
||||
// query. Synthesized keys are exempt: the sub-query skips keys absent from metadata.
|
||||
if skipResourceFilter && !synthesized {
|
||||
filtered := make([]*telemetrytypes.TelemetryFieldKey, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if k.FieldContext != telemetrytypes.FieldContextResource {
|
||||
filtered = append(filtered, k)
|
||||
filtered := make([]*telemetrytypes.LogicalField, 0, len(logicalFields))
|
||||
for _, logical := range logicalFields {
|
||||
if logical.FieldContext != telemetrytypes.FieldContextResource {
|
||||
filtered = append(filtered, logical)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil, warnings, nil
|
||||
}
|
||||
keys = filtered
|
||||
logicalFields = filtered
|
||||
}
|
||||
|
||||
conds := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
cond, err := c.conditionForKey(ctx, orgID, startNs, endNs, k, operator, value, sb)
|
||||
conds := make([]string, 0, len(logicalFields))
|
||||
for _, logical := range logicalFields {
|
||||
cond, err := c.conditionForLogicalField(ctx, orgID, startNs, endNs, logical, operator, value, sb)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -283,28 +279,28 @@ func (c *conditionBuilder) ConditionFor(
|
||||
return conds, warnings, nil
|
||||
}
|
||||
|
||||
func (c *conditionBuilder) conditionForKey(
|
||||
func (c *conditionBuilder) conditionForLogicalField(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
startNs uint64,
|
||||
endNs uint64,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
logical *telemetrytypes.LogicalField,
|
||||
operator qbtypes.FilterOperator,
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
) (string, error) {
|
||||
if c.isSpanScopeField(key.Name) {
|
||||
return c.buildSpanScopeCondition(key, operator, value, startNs)
|
||||
if c.isSpanScopeField(logical.Name) {
|
||||
return c.buildSpanScopeCondition(logical.Single(), operator, value, startNs)
|
||||
}
|
||||
|
||||
condition, err := c.conditionFor(ctx, orgID, startNs, endNs, key, operator, value, sb)
|
||||
condition, err := c.conditionFor(ctx, orgID, startNs, endNs, logical, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if operator.AddDefaultExistsFilter() {
|
||||
// skip adding exists filter for intrinsic fields
|
||||
field, _ := c.fm.FieldFor(ctx, orgID, startNs, endNs, key)
|
||||
field, _ := c.fm.FieldFor(ctx, orgID, startNs, endNs, logical.Single())
|
||||
if slices.Contains(maps.Keys(IntrinsicFields), field) ||
|
||||
slices.Contains(maps.Keys(IntrinsicFieldsDeprecated), field) ||
|
||||
slices.Contains(maps.Keys(CalculatedFields), field) ||
|
||||
@@ -312,7 +308,7 @@ func (c *conditionBuilder) conditionForKey(
|
||||
return condition, nil
|
||||
}
|
||||
|
||||
existsCondition, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorExists, nil, sb)
|
||||
existsCondition, err := c.conditionFor(ctx, orgID, startNs, endNs, logical, qbtypes.FilterOperatorExists, nil, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
148
pkg/telemetryschema/tracestelemetryschema/family_test.go
Normal file
148
pkg/telemetryschema/tracestelemetryschema/family_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package tracestelemetryschema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
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"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// familyFixture returns the deployment.environment(.name) family keys as trace
|
||||
// resource attributes (with the canonical evolution timeline), a metadata map
|
||||
// holding them, and a time range inside the JSON-column window.
|
||||
func familyFixture() (current, old *telemetrytypes.TelemetryFieldKey, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey, startNs, endNs uint64) {
|
||||
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
|
||||
newKey := func(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Evolutions: MockEvolutionData(releaseTime),
|
||||
}
|
||||
}
|
||||
current = newKey("deployment.environment.name")
|
||||
old = newKey("deployment.environment")
|
||||
fieldKeys = map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
current.Name: {current},
|
||||
old.Name: {old},
|
||||
}
|
||||
return current, old, fieldKeys, uint64(1747947419000000000), uint64(1747983448000000000)
|
||||
}
|
||||
|
||||
// memberValueExprs returns each member's own FieldFor output; family
|
||||
// expressions must be exactly the composition of these.
|
||||
func memberValueExprs(t *testing.T, fm qbtypes.FieldMapper, startNs, endNs uint64, members ...*telemetrytypes.TelemetryFieldKey) []string {
|
||||
t.Helper()
|
||||
exprs := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
expr, err := fm.FieldFor(context.Background(), valuer.UUID{}, startNs, endNs, member)
|
||||
require.NoError(t, err)
|
||||
exprs = append(exprs, expr)
|
||||
}
|
||||
return exprs
|
||||
}
|
||||
|
||||
func TestConditionForFamilyMergesMembersCurrentFirst(t *testing.T) {
|
||||
current, old, fieldKeys, startNs, endNs := familyFixture()
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
|
||||
// The requested spelling is the old name; precedence must still be
|
||||
// current-first.
|
||||
requested := &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment"}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conds, warnings, err := cb.ConditionFor(context.Background(), valuer.UUID{}, startNs, endNs, requested, fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "production", sb)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, warnings, "a family is one logical field, never ambiguous with itself")
|
||||
require.Len(t, conds, 1)
|
||||
|
||||
exprs := memberValueExprs(t, fm, startNs, endNs, current, old)
|
||||
family := fmt.Sprintf("COALESCE(NULLIF(%s, ''), NULLIF(%s, ''), '')", exprs[0], exprs[1])
|
||||
|
||||
sb.Where(conds...)
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Contains(t, sql, family+" = ?")
|
||||
// Equal adds the default exists filter: presence of any member.
|
||||
assert.Contains(t, sql, fmt.Sprintf("(%s IS NOT NULL OR %s IS NOT NULL)", exprs[0], exprs[1]))
|
||||
assert.Equal(t, []any{"production"}, args)
|
||||
}
|
||||
|
||||
func TestConditionForFamilyNegativeKeepsKeylessRows(t *testing.T) {
|
||||
current, old, fieldKeys, startNs, endNs := familyFixture()
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
|
||||
requested := &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conds, _, err := cb.ConditionFor(context.Background(), valuer.UUID{}, startNs, endNs, requested, fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotEqual, "production", sb)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, conds, 1)
|
||||
|
||||
exprs := memberValueExprs(t, fm, startNs, endNs, current, old)
|
||||
sb.Where(conds...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
// The trailing '' makes rows without any member read '' (single-key map
|
||||
// semantics), so `!=` keeps including them; no exists filter is added.
|
||||
assert.Contains(t, sql, fmt.Sprintf("COALESCE(NULLIF(%s, ''), NULLIF(%s, ''), '') <> ?", exprs[0], exprs[1]))
|
||||
assert.NotContains(t, sql, "IS NOT NULL OR")
|
||||
}
|
||||
|
||||
func TestConditionForFamilyExists(t *testing.T) {
|
||||
current, old, fieldKeys, startNs, endNs := familyFixture()
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
|
||||
requested := &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conds, _, err := cb.ConditionFor(context.Background(), valuer.UUID{}, startNs, endNs, requested, fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotExists, nil, sb)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, conds, 1)
|
||||
|
||||
exprs := memberValueExprs(t, fm, startNs, endNs, current, old)
|
||||
sb.Where(conds...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Contains(t, sql, fmt.Sprintf("NOT (%s IS NOT NULL OR %s IS NOT NULL)", exprs[0], exprs[1]))
|
||||
}
|
||||
|
||||
// A single-member key's condition is byte-identical to the pre-family shape:
|
||||
// composition only appears when metadata proves a second member.
|
||||
func TestConditionForSingleMemberIsUnchanged(t *testing.T) {
|
||||
current, _, _, startNs, endNs := familyFixture()
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
|
||||
soloKeys := map[string][]*telemetrytypes.TelemetryFieldKey{current.Name: {current}}
|
||||
requested := &telemetrytypes.TelemetryFieldKey{Name: current.Name}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conds, _, err := cb.ConditionFor(context.Background(), valuer.UUID{}, startNs, endNs, requested, soloKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "production", sb)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, conds, 1)
|
||||
|
||||
exprs := memberValueExprs(t, fm, startNs, endNs, current)
|
||||
sb.Where(conds...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Contains(t, sql, exprs[0]+" = ?")
|
||||
assert.NotContains(t, sql, "COALESCE")
|
||||
}
|
||||
|
||||
func TestColumnExpressionForFamilyGroupBy(t *testing.T) {
|
||||
current, old, fieldKeys, startNs, endNs := familyFixture()
|
||||
fm := NewFieldMapper()
|
||||
|
||||
expr, err := fm.ColumnExpressionFor(context.Background(), valuer.UUID{}, startNs, endNs,
|
||||
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, telemetrytypes.FieldDataTypeString, fieldKeys)
|
||||
require.NoError(t, err)
|
||||
|
||||
exprs := memberValueExprs(t, fm, startNs, endNs, current, old)
|
||||
family := fmt.Sprintf("COALESCE(NULLIF(%s, ''), NULLIF(%s, ''), '')", exprs[0], exprs[1])
|
||||
guard := fmt.Sprintf("(%s IS NOT NULL OR %s IS NOT NULL)", exprs[0], exprs[1])
|
||||
assert.Equal(t, fmt.Sprintf("multiIf(%s, %s, NULL)", guard, family), expr)
|
||||
}
|
||||
@@ -336,6 +336,68 @@ func (m *fieldMapper) resolveColumnExprs(
|
||||
return exprs, existExprs, columns, nil
|
||||
}
|
||||
|
||||
// logicalForResolvedColumn upgrades a directly-resolvable key (the FieldFor
|
||||
// probe succeeded) to its family when the metadata map proves membership;
|
||||
// otherwise the key stays a single-member logical field.
|
||||
func logicalForResolvedColumn(field *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) *telemetrytypes.LogicalField {
|
||||
for _, logical := range querybuilder.MatchingLogicalFields(field, keys) {
|
||||
if logical.IsFamily() &&
|
||||
logical.FieldContext == field.FieldContext &&
|
||||
(field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || logical.FieldDataType == field.FieldDataType) {
|
||||
return logical
|
||||
}
|
||||
}
|
||||
return telemetrytypes.SingleLogicalField(field.Name, field)
|
||||
}
|
||||
|
||||
// upgradeToFamilies swaps single-member candidates for their family when the
|
||||
// metadata map proves membership. Candidate order and every non-family
|
||||
// candidate stay exactly as the legacy flow produced them; sibling candidates
|
||||
// of an already-emitted family are dropped rather than duplicated.
|
||||
func upgradeToFamilies(field *telemetrytypes.TelemetryFieldKey, candidates []*telemetrytypes.LogicalField, keys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.LogicalField {
|
||||
var families []*telemetrytypes.LogicalField
|
||||
for _, logical := range querybuilder.MatchingLogicalFields(field, keys) {
|
||||
if logical.IsFamily() {
|
||||
families = append(families, logical)
|
||||
}
|
||||
}
|
||||
if len(families) == 0 {
|
||||
return candidates
|
||||
}
|
||||
|
||||
out := make([]*telemetrytypes.LogicalField, 0, len(candidates))
|
||||
emitted := make(map[*telemetrytypes.LogicalField]bool)
|
||||
for _, candidate := range candidates {
|
||||
var family *telemetrytypes.LogicalField
|
||||
for _, fam := range families {
|
||||
if fam.FieldContext != candidate.FieldContext || fam.FieldDataType != candidate.FieldDataType {
|
||||
continue
|
||||
}
|
||||
memberOfFamily := candidate.Single().Name == field.Name
|
||||
for _, member := range fam.Members {
|
||||
if member.Name == candidate.Single().Name {
|
||||
memberOfFamily = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if memberOfFamily {
|
||||
family = fam
|
||||
break
|
||||
}
|
||||
}
|
||||
if family == nil {
|
||||
out = append(out, candidate)
|
||||
continue
|
||||
}
|
||||
if emitted[family] {
|
||||
continue
|
||||
}
|
||||
emitted[family] = true
|
||||
out = append(out, family)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ColumnExpressionFor returns the bare (unaliased) SQL expression for the field, resolving
|
||||
// unknown keys via CandidateKeys and wrapping guardable columns with exists-guard multiIfs
|
||||
// so an absent key yields NULL.
|
||||
@@ -348,18 +410,23 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
) (string, error) {
|
||||
|
||||
// Resolve the candidate column(s).
|
||||
var candidates []*telemetrytypes.TelemetryFieldKey
|
||||
// Resolve the candidate logical field(s).
|
||||
var candidates []*telemetrytypes.LogicalField
|
||||
switch _, err := m.FieldFor(ctx, orgID, startNs, endNs, field); {
|
||||
case err == nil:
|
||||
candidates = []*telemetrytypes.TelemetryFieldKey{field}
|
||||
// A directly-resolvable key upgrades to its family when the metadata
|
||||
// map proves membership; otherwise it stays single-member.
|
||||
candidates = []*telemetrytypes.LogicalField{logicalForResolvedColumn(field, keys)}
|
||||
case errors.Is(err, qbtypes.ErrColumnNotFound):
|
||||
// column (when the bare name is one) plus metadata matches, else synthesized
|
||||
// type-variant keys.
|
||||
candidates = m.CandidateKeys(ctx, orgID, field, nil, keys)
|
||||
if len(candidates) == 0 {
|
||||
// The legacy candidate flow, unchanged: column (when the bare name is
|
||||
// one) plus metadata matches, else synthesized type-variant keys. The
|
||||
// family step below only swaps candidates for their family; it never
|
||||
// changes candidate order or non-family behavior.
|
||||
raw := m.CandidateKeys(ctx, orgID, field, nil, keys)
|
||||
if len(raw) == 0 {
|
||||
return "", errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
|
||||
}
|
||||
candidates = upgradeToFamilies(field, querybuilder.WrapAsLogicalFields(field.Name, raw), keys)
|
||||
default:
|
||||
return "", err
|
||||
}
|
||||
@@ -373,21 +440,21 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
dummyValue = 0.0
|
||||
}
|
||||
stmts := make([]string, 0, len(candidates)*2)
|
||||
for _, key := range candidates {
|
||||
value, err := m.FieldFor(ctx, orgID, startNs, endNs, key)
|
||||
for _, logical := range candidates {
|
||||
value, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, m, logical)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
guard, err := m.existsExpressionFor(ctx, orgID, startNs, endNs, key, true)
|
||||
guard, err := querybuilder.LogicalExistsExpr(ctx, orgID, startNs, endNs, m, logical, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
coerced := value
|
||||
// a time column keeps its native type; coercing it would yield seconds
|
||||
if temporal, err := m.columnIsTemporal(ctx, startNs, endNs, key); err != nil {
|
||||
if temporal, err := m.logicalIsTemporal(ctx, startNs, endNs, logical); err != nil {
|
||||
return "", err
|
||||
} else if !temporal {
|
||||
coerced, _ = querybuilder.DataTypeCollisionHandledFieldName(key, dummyValue, value, qbtypes.FilterOperatorUnknown)
|
||||
coerced, _ = querybuilder.DataTypeCollisionHandledFieldName(logical.Single(), dummyValue, value, qbtypes.FilterOperatorUnknown)
|
||||
}
|
||||
stmts = append(stmts, guard, coerced)
|
||||
}
|
||||
@@ -395,13 +462,14 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
}
|
||||
|
||||
if len(candidates) == 1 {
|
||||
value, err := m.FieldFor(ctx, orgID, startNs, endNs, candidates[0])
|
||||
logical := candidates[0]
|
||||
value, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, m, logical)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
exprs, existExprs, _, _ := m.resolveColumnExprs(ctx, startNs, endNs, candidates[0])
|
||||
if len(exprs) == 1 && len(existExprs) == 1 {
|
||||
guard, err := m.existsExpressionFor(ctx, orgID, startNs, endNs, candidates[0], true)
|
||||
exprs, existExprs, _, _ := m.resolveColumnExprs(ctx, startNs, endNs, logical.Single())
|
||||
if !logical.IsFamily() && len(exprs) == 1 && len(existExprs) == 1 {
|
||||
guard, err := querybuilder.LogicalExistsExpr(ctx, orgID, startNs, endNs, m, logical, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -413,12 +481,12 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
// Multiple candidates (collision / synth): multiIf picks the first that exists,
|
||||
// stringified so branches share a type.
|
||||
args := make([]string, 0, len(candidates))
|
||||
for _, key := range candidates {
|
||||
value, err := m.FieldFor(ctx, orgID, startNs, endNs, key)
|
||||
for _, logical := range candidates {
|
||||
value, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, m, logical)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
guard, err := m.existsExpressionFor(ctx, orgID, startNs, endNs, key, true)
|
||||
guard, err := querybuilder.LogicalExistsExpr(ctx, orgID, startNs, endNs, m, logical, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -427,6 +495,15 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
return fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", ")), nil
|
||||
}
|
||||
|
||||
// logicalIsTemporal reports whether the logical field resolves to a single time
|
||||
// column. A family is attribute-backed and never temporal.
|
||||
func (m *fieldMapper) logicalIsTemporal(ctx context.Context, startNs, endNs uint64, logical *telemetrytypes.LogicalField) (bool, error) {
|
||||
if logical.IsFamily() {
|
||||
return false, nil
|
||||
}
|
||||
return m.columnIsTemporal(ctx, startNs, endNs, logical.Single())
|
||||
}
|
||||
|
||||
// columnIsTemporal reports whether key resolves to a single time column, after evolution
|
||||
// selection. Multiple columns mean an attribute-map union, which is never temporal.
|
||||
func (m *fieldMapper) columnIsTemporal(ctx context.Context, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey) (bool, error) {
|
||||
@@ -522,7 +599,8 @@ func (m *fieldMapper) CandidateKeys(ctx context.Context, _ valuer.UUID, field *t
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fieldMapper) existsExpressionFor(
|
||||
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
|
||||
func (m *fieldMapper) ExistsFor(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
tsStart, tsEnd uint64,
|
||||
|
||||
@@ -113,6 +113,21 @@ func BuildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytype
|
||||
FieldDataType: telemetrytypes.FieldDataTypeBool,
|
||||
},
|
||||
},
|
||||
// both spellings of an enabled semantic-convention family
|
||||
"deployment.environment.name": {
|
||||
{
|
||||
Name: "deployment.environment.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
"deployment.environment": {
|
||||
{
|
||||
Name: "deployment.environment",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, keys := range keysMap {
|
||||
for _, key := range keys {
|
||||
|
||||
@@ -34,6 +34,11 @@ type FieldMapper interface {
|
||||
// the name (or `{context}.{name}`) first, else synthesized type-variant keys for sources
|
||||
// that support it, else nil (caller errors). value is the filter operand, nil otherwise.
|
||||
CandidateKeys(ctx context.Context, orgID valuer.UUID, field *telemetrytypes.TelemetryFieldKey, value any, keys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey
|
||||
// ExistsFor returns the existence predicate for a single physical key (negated when
|
||||
// exists is false), self-contained and arg-free so it can guard column expressions.
|
||||
// It is the per-member primitive querybuilder.LogicalExistsExpr and the numeric branch
|
||||
// of querybuilder.LogicalValueExpr compose family expressions from.
|
||||
ExistsFor(ctx context.Context, orgID valuer.UUID, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey, exists bool) (string, error)
|
||||
}
|
||||
|
||||
// ConditionBuilder builds the conditions for a filter term. The builder owns key resolution:
|
||||
|
||||
69
pkg/types/telemetrytypes/logical_field.go
Normal file
69
pkg/types/telemetrytypes/logical_field.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package telemetrytypes
|
||||
|
||||
import "strings"
|
||||
|
||||
// LogicalField is resolution output: one queryable field, addressed by the
|
||||
// spelling the request used, backed by the physical member keys that store it.
|
||||
//
|
||||
// The resolver expresses ambiguity ("possibly different fields sharing a
|
||||
// name") as a []*LogicalField — never inside one LogicalField. Within one
|
||||
// LogicalField, members are alternate physical spellings of the same field
|
||||
// (a semantic-convention family), ordered current-first; compilers merge
|
||||
// them into one expression with current-wins precedence. Across the slice,
|
||||
// compilers build one condition per LogicalField and combine per the
|
||||
// operator, exactly as they combine ambiguous keys.
|
||||
//
|
||||
// Members always has at least one entry. A non-family field has exactly
|
||||
// one. Members alias the metadata map entries and must not be mutated.
|
||||
type LogicalField struct {
|
||||
// Name is the requested spelling. It is the response identity: aliases,
|
||||
// series labels, and warnings use it, so responses echo the request.
|
||||
Name string
|
||||
|
||||
// The physical identity every member shares. Members with a different
|
||||
// signal, field context, or data type belong to different logical
|
||||
// fields by definition.
|
||||
Signal Signal
|
||||
FieldContext FieldContext
|
||||
FieldDataType FieldDataType
|
||||
|
||||
// Members are the physical keys that store this field, ordered
|
||||
// current-first. Each member carries its own physical facts
|
||||
// (Materialized, Evolutions, JSONPlan, ...), so per-member accessors
|
||||
// need no sibling information.
|
||||
Members []*TelemetryFieldKey
|
||||
}
|
||||
|
||||
// SingleLogicalField wraps one physical key as its own logical field.
|
||||
func SingleLogicalField(name string, key *TelemetryFieldKey) *LogicalField {
|
||||
return &LogicalField{
|
||||
Name: name,
|
||||
Signal: key.Signal,
|
||||
FieldContext: key.FieldContext,
|
||||
FieldDataType: key.FieldDataType,
|
||||
Members: []*TelemetryFieldKey{key},
|
||||
}
|
||||
}
|
||||
|
||||
// Single returns the only member. It is the accessor for signals whose
|
||||
// logical fields are always single-member (everything except traces today).
|
||||
func (l *LogicalField) Single() *TelemetryFieldKey {
|
||||
return l.Members[0]
|
||||
}
|
||||
|
||||
// IsFamily reports whether the field has more than one physical member.
|
||||
func (l *LogicalField) IsFamily() bool {
|
||||
return len(l.Members) > 1
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer for warning messages.
|
||||
func (l *LogicalField) String() string {
|
||||
if len(l.Members) == 1 {
|
||||
return l.Members[0].String()
|
||||
}
|
||||
names := make([]string, 0, len(l.Members))
|
||||
for _, member := range l.Members {
|
||||
names = append(names, member.Name)
|
||||
}
|
||||
return l.Name + "(" + l.FieldContext.StringValue() + ", " + l.FieldDataType.StringValue() + ", members: " + strings.Join(names, ", ") + ")"
|
||||
}
|
||||
Reference in New Issue
Block a user