Files
signoz/pkg/querybuilder/resolve.go
Srikanth Chekuri 9c886be120 chore(querybuilder): compile every signal through one storage contract (#12802)
The semantic convention family as first call citizen revealed that the
current state of the query builder needs a bit refactoring for long term
maintenance.

The `FieldMapper` and `ConditionBuilder` are now one abstraction
`Storage`.

A storage now answers
- what the compiler cannot know i.e one read per field key (the bare
SQL, the membership present or absent, what an absent row reads, and
whether the read keeps its type or filters only).
- the fallback for a key metadata does not report
- its traits
- and one Condition compilation part.

And we introduce a new type to use in the system, `Resolved`

```
// Resolved is what resolution produces for one key: its meanings, and how
// they came to be. It is the only thing the compilers receive. Compile it
// with the operator and value it was resolved with.
type Resolved struct {
	Key    *telemetrytypes.TelemetryFieldKey
	Fields []*telemetrytypes.LogicalField
	// FromFallback: the fields came from the storage's fallback, not from
	// metadata matches.
	FromFallback bool
	// Ambiguous: the matches held several interpretations.
	Ambiguous bool
	// Skipped: the storage contributes nothing for this key.
	Skipped  bool
	Warnings []string
}
```

The prepared SQL has no changes, where it changed, it specifically made
the expression better by removing the redundant part.

- The prepared SQL remains identical with this refactoring
- No changes to integration tests


Assisted-by: Claude Fable 5.1
2026-09-15 11:36:06 +00:00

216 lines
7.9 KiB
Go

package querybuilder
import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"golang.org/x/exp/maps"
)
// NewQueryInfo binds the context of one query and evaluates the query-path
// flags one time. A nil flagger keeps resolution literal and the log body in
// its legacy column.
func NewQueryInfo(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, signal telemetrytypes.Signal, metric *telemetrytypes.MetricContext, startNs, endNs uint64) qbtypes.QueryInfo {
q := qbtypes.QueryInfo{
StartNs: startNs,
EndNs: endNs,
Signal: signal,
Metric: metric,
FamiliesOn: semconvFamiliesEnabled(ctx, orgID, fl),
}
if fl != nil {
q.BodyJSONOn = fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID))
}
return q
}
// Resolve turns one requested key into its meanings, one time for each
// use. The order is the same for every storage, in the filter and in every
// select field, group by, order by, and aggregation:
//
// 1. matches: the metadata keys under the key's spellings, grouped into
// families when the flag is on. A key under one of the storage's own
// contexts matches its own context first, then a column the storage
// knows under that context, and only then as if it had no context.
// 2. ambiguity: in a filter, several interpretations settle by the
// resource-over-attribute policy, with a warning. A select field, group
// by, order by, or aggregation keeps every interpretation in metadata
// order and folds them.
// 3. intrinsic column first: for a bare key, a column every row has leads.
// Metadata can report the column, or the storage's own tables can. When
// only the storage knows the column, same-named metadata keys of a
// contradicting type drop.
// 4. fallback: with no match, the storage's fallback keys. The not-found
// warning fires only when every one of them is a guess.
func Resolve(
ctx context.Context,
q qbtypes.QueryInfo,
storage qbtypes.Storage,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey,
) (qbtypes.Resolved, error) {
traits := storage.Traits()
lookup := key
matches := matchingLogicalFields(q.FamiliesOn, q.Signal, key, fieldKeys)
if len(matches) == 0 && slices.Contains(traits.OwnContexts, key.FieldContext) {
// a column the storage knows under the key's own context is the key
// as written, and only a miss corrects to the bare spelling
if fallback, err := storage.Fallback(ctx, q, key, operator, value); err == nil {
if column := alwaysPresent(ctx, q, storage, fallback); column != nil {
return qbtypes.Resolved{Key: key, Fields: []*telemetrytypes.LogicalField{column}, FromFallback: true}, nil
}
}
lookup = telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextUnspecified, key.FieldDataType)
matches = matchingLogicalFields(q.FamiliesOn, q.Signal, lookup, fieldKeys)
}
resolved := qbtypes.Resolved{Key: key, Ambiguous: len(matches) > 1}
fields := matches
if operator != qbtypes.FilterOperatorUnknown {
var warning string
fields, warning = ResolveLogicalFields(key, matches)
if warning != "" {
resolved.Warnings = append(resolved.Warnings, warning)
}
}
if lookup.FieldContext == telemetrytypes.FieldContextUnspecified && len(fields) > 0 {
fields = intrinsicColumnFirst(ctx, q, storage, key, operator, value, fields)
}
if len(fields) > 0 {
resolved.Fields = fields
return resolved, nil
}
fallback, err := storage.Fallback(ctx, q, key, operator, value)
if err != nil {
return qbtypes.Resolved{}, err
}
if len(fallback) == 0 {
if traits.UnknownKey == qbtypes.IgnoreUnknownKey {
resolved.Skipped = true
return resolved, nil
}
return qbtypes.Resolved{}, NewKeyNotFoundError(key.Name, maps.Keys(fieldKeys))
}
resolved.FromFallback = true
resolved.Fields = fallback
if fallbackIsGuess(ctx, q, storage, fallback) {
resolved.Warnings = append(resolved.Warnings, NewKeyNotFoundWarning(key.Name))
}
return resolved, nil
}
// ResolveColumn resolves one key for a select field, group by, order by, or
// aggregation, and renders its column expression.
func ResolveColumn(
ctx context.Context,
q qbtypes.QueryInfo,
storage qbtypes.Storage,
key *telemetrytypes.TelemetryFieldKey,
target telemetrytypes.FieldDataType,
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, error) {
resolved, err := Resolve(ctx, q, storage, key, qbtypes.FilterOperatorUnknown, nil, fieldKeys)
if err != nil {
return "", err
}
return Column(ctx, q, storage, resolved, target)
}
// intrinsicColumnFirst puts a column every row has first for a bare key.
// The column comes from the matches when metadata reports it. Otherwise it
// comes from the storage's own fallback. A metadata gap then degrades to
// the correct column, never to a corrupt metadata key. Only in that gap
// does a match of a contradicting data type drop. When metadata reports the
// column too, every match reads.
func intrinsicColumnFirst(
ctx context.Context,
q qbtypes.QueryInfo,
storage qbtypes.Storage,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
fields []*telemetrytypes.LogicalField,
) []*telemetrytypes.LogicalField {
column := alwaysPresent(ctx, q, storage, fields)
fromFallback := false
if column == nil {
// a fallback that cannot answer this term has no column for it, and
// its error belongs to the no-match path
if fallback, err := storage.Fallback(ctx, q, key, operator, value); err == nil {
column = alwaysPresent(ctx, q, storage, fallback)
fromFallback = column != nil
}
}
if column == nil {
return fields
}
out := make([]*telemetrytypes.LogicalField, 0, len(fields)+1)
out = append(out, column)
for _, logical := range fields {
if logical == column || sameFact(logical, column) {
continue
}
if fromFallback && !dataTypesConsistent(column.FieldDataType, logical.FieldDataType) {
continue
}
out = append(out, logical)
}
return out
}
// alwaysPresent returns the first field every row has. A field the storage
// cannot test for presence is not that field.
func alwaysPresent(ctx context.Context, q qbtypes.QueryInfo, storage qbtypes.Storage, fields []*telemetrytypes.LogicalField) *telemetrytypes.LogicalField {
for _, logical := range fields {
if logical.IsFamily() {
continue
}
read, err := storage.Read(ctx, q, logical.Single())
if err == nil && read.WhenAbsent == qbtypes.AlwaysPresent {
return logical
}
}
return nil
}
func sameFact(a, b *telemetrytypes.LogicalField) bool {
return !a.IsFamily() && !b.IsFamily() &&
a.FieldContext == b.FieldContext && a.Single().Name == b.Single().Name
}
// dataTypesConsistent reports whether a metadata key's data type can
// describe the same stored value as the column's. An untyped metadata key
// matches every type. A column without a field data type (a time column)
// matches no type. The numeric kinds match each other.
func dataTypesConsistent(column, entry telemetrytypes.FieldDataType) bool {
if entry == telemetrytypes.FieldDataTypeUnspecified {
return true
}
if column == telemetrytypes.FieldDataTypeUnspecified {
return false
}
if column == entry {
return true
}
return isNumber(column) && isNumber(entry)
}
func isNumber(dt telemetrytypes.FieldDataType) bool {
return dt == telemetrytypes.FieldDataTypeInt64 || dt == telemetrytypes.FieldDataTypeFloat64 || dt == telemetrytypes.FieldDataTypeNumber
}
// fallbackIsGuess reports whether every fallback key is a guess. A column
// the storage knows is not one, and its presence means the key was found.
func fallbackIsGuess(ctx context.Context, q qbtypes.QueryInfo, storage qbtypes.Storage, fields []*telemetrytypes.LogicalField) bool {
return alwaysPresent(ctx, q, storage, fields) == nil
}