Compare commits

...

4 Commits

Author SHA1 Message Date
srikanthccv
3cba59f689 proto: prototype notes for review
Assisted-by: Claude Fable 5
2026-09-03 16:17:58 +05:30
srikanthccv
540ab020c1 proto: one generic coerced-column renderer behind traces and logs
RenderCoercedColumn owns the guarded, coerced multiIf of group-by,
order, and aggregation-argument columns; the traces and logs mappers
answer three leaf questions (RawRead, Uncoerced, BareCandidate) and
delegate their coerced modes. Raw select keeps its per-signal tails:
they differ by storage in more dimensions than they share. Every golden
stays byte-identical.

Assisted-by: Claude Fable 5
2026-09-03 16:17:58 +05:30
srikanthccv
634bfef8aa proto: one generic term compiler behind six condition builders
CompileTerm owns the flow of a filter term - evidence, amendment,
synthesis, the resource-filter policy, and the per-field loop - and the
six condition builders become delegates that implement the three
TermSchema methods (AmendEvidence, Synthesize, CompileField) plus their
term-level intercepts. CompileFieldWithSharedOperators is the canonical
per-field compile: the shared operator switch with the default exists
guard. Every golden stays byte-identical.

Assisted-by: Claude Fable 5
2026-09-03 16:17:58 +05:30
srikanthccv
9f1e95ec01 proto: pin the rule-state-history operator forms before the port
The builder had no tests, and it carries two shapes that exist nowhere
else: IN binds the whole list to one placeholder, and the exists
predicate renders a constant for intrinsic columns but a JSONHas
membership check for labels. The pins fix the pre-port behavior so the
generic-flow port that follows proves its parity instead of asserting
it.

Assisted-by: Claude Fable 5
2026-09-03 16:13:15 +05:30
13 changed files with 751 additions and 410 deletions

View File

@@ -0,0 +1,100 @@
# Tree-model prototype (proto/tree-model)
A local prototype of the proposed end-state architecture, built on
`feat/semconv-phase2-signals` with byte-parity as the acceptance bar: every
golden and every integration case must produce identical SQL. The prototype
exists to let a reviewer judge the model empirically — how small the generic
core really is, how large the per-signal residue really is, and where the
line between them falls.
## What became generic
### The term compiler — `pkg/querybuilder/term.go`
`CompileTerm` is the flow of one filter term, written once:
1. resolve the evidence (`ResolveLogicalFields`, ambiguity warning),
2. amend it with intrinsic storage (`TermSchema.AmendEvidence`),
3. synthesize when it is empty (`TermSchema.Synthesize`),
4. apply the resource-filter policy (`SkipResourcePolicy`),
5. compile every field (`TermSchema.CompileField`), collecting warnings in
a fixed order.
Six condition builders became delegates to it: traces, logs, metrics, audit,
rule state history, and the resource filter. Each keeps a term-level
intercept in front where one exists (`search()` on logs, the function-operator
reject or skip), and implements the three `TermSchema` methods. The six
hand-rolled copies of the flow are gone; a change to the flow — the order of
warnings, the synthesized-exemption of the resource drop — now has one home.
`CompileFieldWithSharedOperators` is the canonical `CompileField`: the shared
operator switch (`LogicalFamilyCondition`) over the merged or single value
expression, with the default exists guard for positive operators. Traces uses
it for every field; logs and metrics use it for families.
### The coerced column renderer — `pkg/querybuilder/column.go`
`RenderCoercedColumn` renders resolved fields for group-by, order, and
aggregation arguments: every field exists-guarded and coerced in one
`multiIf`, with the NULL group preserved. Traces and logs delegate their
coerced modes to it through three leaf questions (`ColumnSchema`):
- `RawRead` — the uncoerced read of one field (logs overrides the legacy
body path),
- `Uncoerced` — the coercion exemption (traces time columns, logs legacy
body reads),
- `BareCandidate` — the fields that cannot sit inside `multiIf` (arrays).
## What stayed per-signal, and why
- **The single-key operator switches.** Metrics coerces collisions with its
own casts (`toFloat64OrNull`, labels-as-String), logs carries the body
machinery and the body-column index forms, audit and rule state history
have their own switches. Unifying them changes SQL, so byte-parity forbids
it here. This is the real distance to "one operator switch": it exists for
families today, and extending it to singles is a re-pinning exercise, not a
refactor.
- **The resource-filter compile.** Index hints are woven into every operator
case with per-operator polarity rules; the whole field compile stays its
own (`CompileField` overrides wholesale).
- **The raw-select tails.** Traces and logs raw-select shapes differ in more
dimensions than they share (guard tests, stringification, collision
application), so `ColumnExpressionFor`'s Unspecified mode keeps its
per-signal tails. A generic raw renderer would need more knobs than it
removes lines.
- **The column resolution orders.** Which candidates a column stage sees —
the storage probe, the metadata lookups, the swap/append of resolved
spellings — keeps its pinned per-signal order in the mappers. Moving it
into a generic `Resolve` is the model-B step proper, and it needs the
stage-aware synthesis asymmetry (filters synthesize by operand, columns
do not) carried as data.
- **Metadata.** Its merged-value semantics bind query parameters inside
presence guards, which the arg-free `ExistsFor` contract cannot express.
Untouched, as declared.
## The review round (applied)
- The rule-state-history operator forms are pinned in the commit BEFORE the
port (it had no tests and diverges most: IN binds the whole list to one
placeholder, exists renders two ways), so the port proves parity.
- Audit refuses families in its delegate, before the resource drop, so the
wiring tripwire stays loud instead of dropping a resource-context family
silently.
- The logs coerced-column schema value carries the body mode; the flag reads
one time per call. RawRead's dummy-value parameter is a legacy-body shim
and leaves with the legacy body.
- The compile context is named `CompileScope`: "scope" alone collides with
the span search scope, the vocabulary member scope, and the resolution
scope.
## Findings a reviewer should weigh
1. The condition-side consolidation is real and cheap: six flows became one,
with per-signal surface of exactly three methods each, and every golden
stayed byte-identical without adjustment.
2. The column side splits: coerced modes unify on three knobs; raw select
does not pay for unification at today's shapes.
3. The full tree model (candidates as data, one `Resolve`, generic raw
rendering) requires shape reconciliation that byte-parity forbids —
confirming it should ride a forcing feature (the ValueMap reader) with a
re-pinning round, not a standalone refactor.

View File

@@ -22,6 +22,8 @@ func newConditionBuilder(fm qbtypes.FieldMapper) qbtypes.ConditionBuilder {
}
// Rule state history has no resource sub-query, so options are unused.
// ConditionFor rejects the logs-only function operators and hands the term to
// the generic flow; rule state history fields have no family support.
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
orgID valuer.UUID,
@@ -29,43 +31,37 @@ func (c *conditionBuilder) ConditionFor(
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
logicalFields []*telemetrytypes.LogicalField,
_ map[string][]*telemetrytypes.TelemetryFieldKey,
_ qbtypes.ConditionBuilderOptions,
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey,
options qbtypes.ConditionBuilderOptions,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken/search are logs-only functions; reject for rule state history.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}
scope := querybuilder.CompileScope{OrgID: orgID, StartNs: startNs, EndNs: endNs}
return querybuilder.CompileTerm(ctx, scope, c, querybuilder.SkipResourceNone, key, logicalFields, fieldKeys, options, operator, value, sb)
}
// Rule state history fields have no family support, so every logical field
// is single-member and flattens losslessly to its physical key; SingleKeys
// refuses a family, so a wiring mistake fails loudly.
resolved, warning := querybuilder.ResolveLogicalFields(key, logicalFields)
keys, err := querybuilder.SingleKeys(resolved)
if err != nil {
return nil, nil, err
}
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
if len(keys) == 0 {
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
}
// AmendEvidence: rule state history folds no intrinsic storage into the evidence.
func (c *conditionBuilder) AmendEvidence(_ context.Context, _ querybuilder.CompileScope, _ *telemetrytypes.TelemetryFieldKey, fields []*telemetrytypes.LogicalField) []*telemetrytypes.LogicalField {
return fields
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
cond, err := c.conditionForKey(ctx, orgID, startNs, endNs, k, operator, value, sb)
if err != nil {
return nil, nil, err
}
conds = append(conds, cond)
// Synthesize: rule state history synthesizes nothing — an unknown key is an error.
func (c *conditionBuilder) Synthesize(_ context.Context, _ querybuilder.CompileScope, key *telemetrytypes.TelemetryFieldKey, _ qbtypes.FilterOperator, _ any, _ map[string][]*telemetrytypes.TelemetryFieldKey) ([]*telemetrytypes.LogicalField, []string, error) {
return nil, nil, querybuilder.NewKeyNotFoundError(key.Name)
}
// CompileField: single keys only — a family is a wiring error for this signal.
func (c *conditionBuilder) CompileField(ctx context.Context, scope querybuilder.CompileScope, logical *telemetrytypes.LogicalField, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, []string, error) {
if logical.IsFamily() {
return "", nil, errors.NewInternalf(errors.CodeInternal, "field %q resolved to a family, and this signal compiles single keys only", logical.Name)
}
return conds, warnings, nil
cond, err := c.conditionForKey(ctx, scope.OrgID, scope.StartNs, scope.EndNs, logical.Single(), operator, value, sb)
return cond, nil, err
}
func (c *conditionBuilder) conditionForKey(

View File

@@ -0,0 +1,80 @@
package implrulestatehistory
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/querybuilder"
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"
)
// The pins below fix the exact operator forms of this builder, including the
// two shapes that exist nowhere else: IN binds the whole list to one
// placeholder (sb.In without spreading), and the exists predicate renders
// "true" for an intrinsic column but a JSONHas membership check for a label.
func TestConditionForPinsOperatorForms(t *testing.T) {
ctx := context.Background()
fm := newFieldMapper()
cb := newConditionBuilder(fm)
intrinsic := &telemetrytypes.TelemetryFieldKey{Name: "state"}
label := &telemetrytypes.TelemetryFieldKey{Name: "deployment"}
cases := []struct {
name string
key *telemetrytypes.TelemetryFieldKey
operator qbtypes.FilterOperator
value any
expectedSQL string
expectedArgs []any
}{
{name: "intrinsic equal", key: intrinsic, operator: qbtypes.FilterOperatorEqual, value: "firing", expectedSQL: "WHERE state = ?", expectedArgs: []any{"firing"}},
{name: "intrinsic not equal", key: intrinsic, operator: qbtypes.FilterOperatorNotEqual, value: "firing", expectedSQL: "WHERE state <> ?", expectedArgs: []any{"firing"}},
{name: "label equal", key: label, operator: qbtypes.FilterOperatorEqual, value: "production", expectedSQL: "WHERE JSONExtractString(labels, 'deployment') = ?", expectedArgs: []any{"production"}},
{name: "greater than", key: &telemetrytypes.TelemetryFieldKey{Name: "unix_milli"}, operator: qbtypes.FilterOperatorGreaterThan, value: int64(123), expectedSQL: "WHERE unix_milli > ?", expectedArgs: []any{int64(123)}},
{name: "like", key: intrinsic, operator: qbtypes.FilterOperatorLike, value: "fir", expectedSQL: "WHERE state LIKE ?", expectedArgs: []any{"fir"}},
{name: "contains", key: intrinsic, operator: qbtypes.FilterOperatorContains, value: "fir", expectedSQL: "WHERE LOWER(state) LIKE LOWER(?)", expectedArgs: []any{"%fir%"}},
{name: "regexp", key: intrinsic, operator: qbtypes.FilterOperatorRegexp, value: "^f", expectedSQL: "WHERE match(state, ?)", expectedArgs: []any{"^f"}},
{name: "between", key: &telemetrytypes.TelemetryFieldKey{Name: "unix_milli"}, operator: qbtypes.FilterOperatorBetween, value: []any{int64(1), int64(2)}, expectedSQL: "WHERE unix_milli BETWEEN ? AND ?", expectedArgs: []any{int64(1), int64(2)}},
{name: "in binds the list to one placeholder", key: intrinsic, operator: qbtypes.FilterOperatorIn, value: []any{"firing", "inactive"}, expectedSQL: "WHERE state IN (?)", expectedArgs: []any{[]any{"firing", "inactive"}}},
{name: "not in binds the list to one placeholder", key: intrinsic, operator: qbtypes.FilterOperatorNotIn, value: []any{"firing"}, expectedSQL: "WHERE state NOT IN (?)", expectedArgs: []any{[]any{"firing"}}},
{name: "intrinsic exists is constant true", key: intrinsic, operator: qbtypes.FilterOperatorExists, value: nil, expectedSQL: "WHERE true", expectedArgs: nil},
{name: "label exists is a membership check", key: label, operator: qbtypes.FilterOperatorExists, value: nil, expectedSQL: "WHERE JSONHas(labels, ?)", expectedArgs: []any{"deployment"}},
{name: "label not exists negates the membership check", key: label, operator: qbtypes.FilterOperatorNotExists, value: nil, expectedSQL: "WHERE not JSONHas(labels, ?)", expectedArgs: []any{"deployment"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{tc.key.Name: {tc.key}}
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(ctx, valuer.UUID{}, 0, 0, tc.key,
querybuilder.MatchingLogicalFields(ctx, valuer.UUID{}, nil, telemetrytypes.SignalUnspecified, nil, tc.key, fieldKeys),
fieldKeys, qbtypes.ConditionBuilderOptions{}, tc.operator, tc.value, sb)
require.NoError(t, err)
require.Len(t, conds, 1)
sb.Where(conds...)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Equal(t, tc.expectedSQL, sql)
assert.Equal(t, tc.expectedArgs, args)
})
}
}
// An unknown key is an error, and a function operator is rejected before
// resolution.
func TestConditionForRejections(t *testing.T) {
ctx := context.Background()
cb := newConditionBuilder(newFieldMapper())
key := &telemetrytypes.TelemetryFieldKey{Name: "missing"}
_, _, err := cb.ConditionFor(ctx, valuer.UUID{}, 0, 0, key, nil, nil, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "x", sqlbuilder.NewSelectBuilder())
assert.ErrorContains(t, err, "not found")
_, _, err = cb.ConditionFor(ctx, valuer.UUID{}, 0, 0, key, nil, nil, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorHasToken, "x", sqlbuilder.NewSelectBuilder())
assert.Error(t, err)
}

View File

@@ -0,0 +1,74 @@
package querybuilder
import (
"context"
"fmt"
"strings"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// ColumnSchema is the per-signal surface of the generic coerced-column
// renderer (group-by, order, and aggregation arguments). The three methods
// are leaf questions about one resolved field; the composition — the guarded,
// coerced multiIf with the NULL group — is RenderCoercedColumn and is written
// once. Raw select keeps its per-signal shapes in the mappers: its tails
// differ by storage in more ways than they share.
type ColumnSchema interface {
// RawRead returns the uncoerced value read of one field. The canonical
// answer is LogicalValueExpr (the merged read for a family, the member's
// own read otherwise); logs overrides it for the legacy body path.
RawRead(ctx context.Context, scope CompileScope, logical *telemetrytypes.LogicalField, dummyValue any) (string, error)
// Uncoerced reports a field whose native type must survive the stage
// coercion: a time column would collapse to seconds (traces), and a
// legacy body read carries its own typing.
Uncoerced(ctx context.Context, scope CompileScope, logical *telemetrytypes.LogicalField) (bool, error)
// BareCandidate reports a field that cannot sit inside Nullable/multiIf
// and renders as its bare read when it is the only candidate (arrays).
BareCandidate(logical *telemetrytypes.LogicalField) bool
}
// RenderCoercedColumn renders resolved fields as one column expression for
// the coerced stages: every field exists-guarded and coerced to the target
// type in a single multiIf, so rows holding none of the candidates keep the
// NULL group of a single key.
func RenderCoercedColumn(
ctx context.Context,
scope CompileScope,
schema ColumnSchema,
fm qbtypes.FieldMapper,
fields []*telemetrytypes.LogicalField,
target telemetrytypes.FieldDataType,
) (string, error) {
if len(fields) == 1 && schema.BareCandidate(fields[0]) {
return schema.RawRead(ctx, scope, fields[0], "")
}
var dummyValue any = ""
if target == telemetrytypes.FieldDataTypeFloat64 {
dummyValue = 0.0
}
stmts := make([]string, 0, len(fields)*2)
for _, logical := range fields {
guard, err := LogicalExistsExpr(ctx, scope.OrgID, scope.StartNs, scope.EndNs, fm, logical, true)
if err != nil {
return "", err
}
read, err := schema.RawRead(ctx, scope, logical, dummyValue)
if err != nil {
return "", err
}
uncoerced, err := schema.Uncoerced(ctx, scope, logical)
if err != nil {
return "", err
}
if !uncoerced {
read, _ = DataTypeCollisionHandledFieldName(logical.Single(), dummyValue, read, qbtypes.FilterOperatorUnknown)
}
stmts = append(stmts, guard, read)
}
return fmt.Sprintf("multiIf(%s, NULL)", strings.Join(stmts, ", ")), nil
}

15
pkg/querybuilder/scope.go Normal file
View File

@@ -0,0 +1,15 @@
package querybuilder
import (
"github.com/SigNoz/signoz/pkg/valuer"
)
// CompileScope carries the compile-time context of one query as one value: the org
// and the time range every physical read (evolution selection, probes) needs.
// The generic term and column compilers thread it instead of loose
// positional parameters, so a dropped axis is a compile error.
type CompileScope struct {
OrgID valuer.UUID
StartNs uint64
EndNs uint64
}

158
pkg/querybuilder/term.go Normal file
View File

@@ -0,0 +1,158 @@
package querybuilder
import (
"context"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
)
// TermSchema is the per-signal surface of the generic term compiler. The
// methods answer what exists and how one resolved field compiles; the flow —
// evidence, synthesis, the resource-filter policy, the per-field loop — is
// CompileTerm and is written once. A signal implements exactly three
// methods; term-level intercepts (search(), function rejects) stay in the
// signal's ConditionFor delegate, in front of the flow.
type TermSchema interface {
// AmendEvidence folds intrinsic storage into non-empty resolved evidence
// (traces prepends the span column for a bare name). Most signals return
// the fields unchanged.
AmendEvidence(ctx context.Context, scope CompileScope, key *telemetrytypes.TelemetryFieldKey, fields []*telemetrytypes.LogicalField) []*telemetrytypes.LogicalField
// Synthesize returns the logical fields for a name resolution found no
// evidence for, with any warnings (not-found advisories). The error is
// terminal (unknown key). A (nil, nil, nil) return skips the term: the
// signal contributes no condition for the name (resource filter).
Synthesize(ctx context.Context, scope CompileScope, key *telemetrytypes.TelemetryFieldKey, operator qbtypes.FilterOperator, value any, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) ([]*telemetrytypes.LogicalField, []string, error)
// CompileField compiles one resolved field into a condition. This is the
// signal's storage residue — body JSON, index hints, its own operator
// forms — and the exists-guard policy, which differs per storage.
// CompileFieldWithSharedOperators is the canonical implementation for
// map-backed fields.
CompileField(ctx context.Context, scope CompileScope, logical *telemetrytypes.LogicalField, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, []string, error)
}
// SkipResourcePolicy states how ConditionBuilderOptions.SkipResourceFilter
// applies to a signal's resolved fields.
type SkipResourcePolicy int
const (
// SkipResourceNone: the option does not apply (metrics, rule state history).
SkipResourceNone SkipResourcePolicy = iota
// SkipResourceDrop: a resource sub-query covers resource fields, so drop
// them from the evidence; when none remain the term is already covered.
// Synthesized fields are exempt: the sub-query skips unknown keys.
SkipResourceDrop
// SkipResourceOnly: the signal stores resource attributes only, so keep
// resource fields and omit everything else (the resource fingerprint
// filter).
SkipResourceOnly
)
// CompileTerm is the generic flow of one filter term: resolve the evidence,
// amend it, synthesize when it is empty, apply the resource-filter policy,
// and compile every field through the signal's CompileField. Warnings keep
// their order: ambiguity first, synthesis advisories second, per-field
// advisories last.
func CompileTerm(
ctx context.Context,
scope CompileScope,
schema TermSchema,
policy SkipResourcePolicy,
key *telemetrytypes.TelemetryFieldKey,
evidence []*telemetrytypes.LogicalField,
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey,
options qbtypes.ConditionBuilderOptions,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
fields, warning := ResolveLogicalFields(key, evidence)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
fields = schema.AmendEvidence(ctx, scope, key, fields)
synthesized := false
if len(fields) == 0 {
synthesizedFields, synthWarnings, err := schema.Synthesize(ctx, scope, key, operator, value, fieldKeys)
if err != nil {
return nil, warnings, err
}
warnings = append(warnings, synthWarnings...)
if len(synthesizedFields) == 0 {
return nil, warnings, nil
}
fields = synthesizedFields
synthesized = true
}
switch policy {
case SkipResourceDrop:
if options.SkipResourceFilter && !synthesized {
filtered := make([]*telemetrytypes.LogicalField, 0, len(fields))
for _, logical := range fields {
if logical.FieldContext != telemetrytypes.FieldContextResource {
filtered = append(filtered, logical)
}
}
if len(filtered) == 0 {
return nil, warnings, nil
}
fields = filtered
}
case SkipResourceOnly:
filtered := make([]*telemetrytypes.LogicalField, 0, len(fields))
for _, logical := range fields {
if logical.FieldContext == telemetrytypes.FieldContextResource {
filtered = append(filtered, logical)
}
}
fields = filtered
}
conds := make([]string, 0, len(fields))
for _, logical := range fields {
cond, fieldWarnings, err := schema.CompileField(ctx, scope, logical, operator, value, sb)
if err != nil {
return nil, nil, err
}
conds = append(conds, cond)
warnings = append(warnings, fieldWarnings...)
}
return conds, warnings, nil
}
// CompileFieldWithSharedOperators is the canonical CompileField for
// map-backed fields: the shared operator switch over the merged (family) or
// single value expression, with the default exists guard AND-ed for positive
// operators when guard is true. The keyless-row contract lives here: the
// guard keeps an empty-string equality from matching rows without the key,
// and its absence on negative operators keeps them set-complement over all
// rows.
func CompileFieldWithSharedOperators(
ctx context.Context,
scope CompileScope,
fm qbtypes.FieldMapper,
logical *telemetrytypes.LogicalField,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
guard bool,
) (string, error) {
condition, err := LogicalFamilyCondition(ctx, scope.OrgID, scope.StartNs, scope.EndNs, fm, logical, operator, value, sb)
if err != nil {
return "", err
}
if guard && operator.AddDefaultExistsFilter() {
existsCondition, err := LogicalFamilyCondition(ctx, scope.OrgID, scope.StartNs, scope.EndNs, fm, logical, qbtypes.FilterOperatorExists, nil, sb)
if err != nil {
return "", err
}
return sb.And(condition, existsCondition), nil
}
return condition, nil
}

View File

@@ -108,7 +108,10 @@ func memberPresenceCondition(sb *sqlbuilder.SelectBuilder, column string, member
return sb.And(conditions...)
}
// SkipResourceFilter is not applicable here: the fingerprint table only stores resource attributes.
// ConditionFor skips the logs-body function operators (they never apply to
// the resource fingerprint table; the main query still evaluates them) and
// hands the term to the generic flow. SkipResourceFilter the option is not
// applicable here: this builder IS the resource sub-query.
func (b *defaultConditionBuilder) ConditionFor(
ctx context.Context,
orgID valuer.UUID,
@@ -116,41 +119,35 @@ func (b *defaultConditionBuilder) ConditionFor(
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
logicalFields []*telemetrytypes.LogicalField,
_ map[string][]*telemetrytypes.TelemetryFieldKey,
_ qbtypes.ConditionBuilderOptions,
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey,
options qbtypes.ConditionBuilderOptions,
op qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
matches := logicalFields
// has/hasAny/hasAll/hasToken are logs-body-only functions; they never apply to the
// resource fingerprint table, so skip them (the main query still evaluates them).
if op.IsFunctionOperator() {
return nil, nil, nil
}
scope := querybuilder.CompileScope{OrgID: orgID, StartNs: startNs, EndNs: endNs}
return querybuilder.CompileTerm(ctx, scope, b, querybuilder.SkipResourceOnly, key, logicalFields, fieldKeys, options, op, value, sb)
}
logicalFields, warning := querybuilder.ResolveLogicalFields(key, matches)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
// AmendEvidence: the fingerprint table folds no intrinsic storage into the evidence.
func (b *defaultConditionBuilder) AmendEvidence(_ context.Context, _ querybuilder.CompileScope, _ *telemetrytypes.TelemetryFieldKey, fields []*telemetrytypes.LogicalField) []*telemetrytypes.LogicalField {
return fields
}
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 logical.FieldContext != telemetrytypes.FieldContextResource {
continue
}
cond, err := b.conditionForLogicalField(ctx, orgID, startNs, endNs, logical, op, value, sb)
if err != nil {
return nil, nil, err
}
conds = append(conds, cond)
}
return conds, warnings, nil
// Synthesize: an unknown key contributes no condition — the caller skips this
// filter entirely, and the main query still evaluates the term.
func (b *defaultConditionBuilder) Synthesize(_ context.Context, _ querybuilder.CompileScope, _ *telemetrytypes.TelemetryFieldKey, _ qbtypes.FilterOperator, _ any, _ map[string][]*telemetrytypes.TelemetryFieldKey) ([]*telemetrytypes.LogicalField, []string, error) {
return nil, nil, nil
}
// CompileField: the fingerprint operator forms with their bloom-index hints,
// for families and singles alike.
func (b *defaultConditionBuilder) CompileField(ctx context.Context, scope querybuilder.CompileScope, logical *telemetrytypes.LogicalField, op qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, []string, error) {
cond, err := b.conditionForLogicalField(ctx, scope.OrgID, scope.StartNs, scope.EndNs, logical, op, value, sb)
return cond, nil, err
}
func (b *defaultConditionBuilder) conditionForLogicalField(

View File

@@ -129,6 +129,8 @@ func (c *conditionBuilder) conditionFor(
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported operator: %v", operator)
}
// ConditionFor rejects the logs-only function operators and hands the term to
// the generic flow; audit fields have no family support.
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
orgID valuer.UUID,
@@ -136,57 +138,46 @@ func (c *conditionBuilder) ConditionFor(
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
logicalFields []*telemetrytypes.LogicalField,
_ map[string][]*telemetrytypes.TelemetryFieldKey,
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey,
options qbtypes.ConditionBuilderOptions,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken/search are logs-only functions; reject for audit.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}
// Audit fields have no family support, so every logical field is
// single-member and flattens losslessly to its physical key; SingleKeys
// refuses a family, so a wiring mistake fails loudly.
resolved, warning := querybuilder.ResolveLogicalFields(key, logicalFields)
keys, err := querybuilder.SingleKeys(resolved)
if err != nil {
return nil, nil, err
}
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
if len(keys) == 0 {
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
}
// Drop resource keys the sub-query already covers; if none remain, skip the term (not an error).
if options.SkipResourceFilter {
filtered := make([]*telemetrytypes.TelemetryFieldKey, 0, len(keys))
for _, k := range keys {
if k.FieldContext != telemetrytypes.FieldContextResource {
filtered = append(filtered, k)
}
// The family refusal runs before the generic flow (and its resource
// drop), so a mis-wired family fails loudly instead of dropping silently.
for _, logical := range logicalFields {
if logical.IsFamily() {
return nil, nil, errors.NewInternalf(errors.CodeInternal, "field %q resolved to a family, and this signal compiles single keys only", logical.Name)
}
if len(filtered) == 0 {
return nil, warnings, nil
}
keys = filtered
}
scope := querybuilder.CompileScope{OrgID: orgID, StartNs: startNs, EndNs: endNs}
return querybuilder.CompileTerm(ctx, scope, c, querybuilder.SkipResourceDrop, key, logicalFields, fieldKeys, options, operator, value, sb)
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
cond, err := c.conditionForKey(ctx, orgID, startNs, endNs, k, operator, value, sb)
if err != nil {
return nil, nil, err
}
conds = append(conds, cond)
// AmendEvidence: audit folds no intrinsic storage into the evidence.
func (c *conditionBuilder) AmendEvidence(_ context.Context, _ querybuilder.CompileScope, _ *telemetrytypes.TelemetryFieldKey, fields []*telemetrytypes.LogicalField) []*telemetrytypes.LogicalField {
return fields
}
// Synthesize: audit synthesizes nothing — an unknown key is an error.
func (c *conditionBuilder) Synthesize(_ context.Context, _ querybuilder.CompileScope, key *telemetrytypes.TelemetryFieldKey, _ qbtypes.FilterOperator, _ any, _ map[string][]*telemetrytypes.TelemetryFieldKey) ([]*telemetrytypes.LogicalField, []string, error) {
return nil, nil, querybuilder.NewKeyNotFoundError(key.Name)
}
// CompileField: audit compiles single keys only — a family is a wiring error,
// because this signal has no family compiler and flattening would silently
// read one spelling.
func (c *conditionBuilder) CompileField(ctx context.Context, scope querybuilder.CompileScope, logical *telemetrytypes.LogicalField, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, []string, error) {
if logical.IsFamily() {
return "", nil, errors.NewInternalf(errors.CodeInternal, "field %q resolved to a family, and this signal compiles single keys only", logical.Name)
}
return conds, warnings, nil
cond, err := c.conditionForKey(ctx, scope.OrgID, scope.StartNs, scope.EndNs, logical.Single(), operator, value, sb)
return cond, nil, err
}
func (c *conditionBuilder) conditionForKey(

View File

@@ -448,6 +448,9 @@ func candidateLookupKeys(key *telemetrytypes.TelemetryFieldKey, fieldKeys map[st
return nil
}
// ConditionFor handles search() (which resolves its own scope) in front and
// hands the term to the generic flow; the logs-specific behavior lives in the
// TermSchema methods below.
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
orgID valuer.UUID,
@@ -461,104 +464,79 @@ func (c *conditionBuilder) ConditionFor(
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
matches := logicalFields
skipResourceFilter := options.SkipResourceFilter
// search() resolves its own (optional) scope; handle it before key resolution.
if operator == qbtypes.FilterOperatorSearch {
return c.conditionForSearch(ctx, orgID, key, value, sb)
}
scope := querybuilder.CompileScope{OrgID: orgID, StartNs: startNs, EndNs: endNs}
return querybuilder.CompileTerm(ctx, scope, c, querybuilder.SkipResourceDrop, key, logicalFields, fieldKeys, options, operator, value, sb)
}
logicalFields, warning := querybuilder.ResolveLogicalFields(key, matches)
// AmendEvidence: logs fold no intrinsic storage into the evidence.
func (c *conditionBuilder) AmendEvidence(_ context.Context, _ querybuilder.CompileScope, _ *telemetrytypes.TelemetryFieldKey, fields []*telemetrytypes.LogicalField) []*telemetrytypes.LogicalField {
return fields
}
// Synthesize: intrinsic log columns pass through as themselves; everything
// else goes to CandidateKeys — fold-contexts get the metadata map so a
// same-named key under another context wins before the prefix folds into the
// key name (matching ColumnExpressionFor), strict contexts pass nil and stay
// honored as-is. The body-JSON functions keep body candidates only.
func (c *conditionBuilder) Synthesize(ctx context.Context, scope querybuilder.CompileScope, key *telemetrytypes.TelemetryFieldKey, operator qbtypes.FilterOperator, value any, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) ([]*telemetrytypes.LogicalField, []string, error) {
_, isIntrinsicColumn := logsV2Columns[key.Name]
var keys []*telemetrytypes.TelemetryFieldKey
switch {
case key.FieldContext == telemetrytypes.FieldContextBody && key.Name == "":
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "missing key for body json search - expected key of the form `body.key` (ex: `body.status`)")
case key.FieldContext == telemetrytypes.FieldContextLog && isIntrinsicColumn:
return querybuilder.WrapAsLogicalFields(key.Name, []*telemetrytypes.TelemetryFieldKey{key}), nil, nil
default:
keys = c.fm.CandidateKeys(ctx, scope.OrgID, key, value, candidateLookupKeys(key, fieldKeys))
if operator.IsFunctionOperator() {
if key.FieldContext != telemetrytypes.FieldContextBody {
// has/hasAny/hasAll/hasToken are body-JSON only
return nil, nil, querybuilder.NewFunctionUnsupportedError(operator)
}
bodyKeys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(keys))
for _, k := range keys {
if k.FieldContext == telemetrytypes.FieldContextBody {
bodyKeys = append(bodyKeys, k)
}
}
keys = bodyKeys
}
if len(keys) == 0 {
return nil, nil, querybuilder.NewKeyNotFoundError(key.Name)
}
return querybuilder.WrapAsLogicalFields(key.Name, keys), []string{querybuilder.NewKeyNotFoundWarning(key.Name)}, nil
}
}
// CompileField: a family compiles through the shared operator switch with the
// same default-exists guard as the single-key path — without it, an
// empty-string equality (and LIKE '%', or an empty CONTAINS) would match
// keyless rows. A single member keeps
// the logs storage residue: body JSON, full-text, and the context-based guard
// policy of conditionForKey.
func (c *conditionBuilder) CompileField(ctx context.Context, scope querybuilder.CompileScope, logical *telemetrytypes.LogicalField, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, []string, error) {
if logical.IsFamily() {
// has/hasAny/hasAll/hasToken are body-JSON only; family members are
// attribute and resource keys, so keep the descriptive error.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return "", nil, err
}
cond, err := querybuilder.CompileFieldWithSharedOperators(ctx, scope, c.fm, logical, operator, value, sb, true)
return cond, nil, err
}
k := logical.Single()
cond, err := c.conditionForKey(ctx, scope.OrgID, scope.StartNs, scope.EndNs, k, operator, value, sb)
if err != nil {
return "", nil, err
}
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
if w := c.bodyFullTextDefaultWarning(ctx, scope.OrgID, scope.StartNs, scope.EndNs, k, operator); w != "" {
warnings = append(warnings, w)
}
synthesized := false
if len(logicalFields) == 0 {
_, isIntrinsicColumn := logsV2Columns[key.Name]
var keys []*telemetrytypes.TelemetryFieldKey
switch {
case key.FieldContext == telemetrytypes.FieldContextBody && key.Name == "":
return nil, warnings, errors.NewInvalidInputf(errors.CodeInvalidInput, "missing key for body json search - expected key of the form `body.key` (ex: `body.status`)")
case key.FieldContext == telemetrytypes.FieldContextLog && isIntrinsicColumn:
keys = []*telemetrytypes.TelemetryFieldKey{key}
default:
// Fold-contexts get the metadata map so a same-named key under another context
// wins before the prefix folds into the key name (matching ColumnExpressionFor);
// strict contexts pass nil and stay honored as-is.
keys = c.fm.CandidateKeys(ctx, orgID, key, value, candidateLookupKeys(key, fieldKeys))
if operator.IsFunctionOperator() {
if key.FieldContext != telemetrytypes.FieldContextBody {
// has/hasAny/hasAll/hasToken are body-JSON only
return nil, warnings, querybuilder.NewFunctionUnsupportedError(operator)
}
bodyKeys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(keys))
for _, k := range keys {
if k.FieldContext == telemetrytypes.FieldContextBody {
bodyKeys = append(bodyKeys, k)
}
}
keys = bodyKeys
}
if len(keys) == 0 {
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
}
synthesized = true
warnings = append(warnings, querybuilder.NewKeyNotFoundWarning(key.Name))
}
logicalFields = querybuilder.WrapAsLogicalFields(key.Name, keys)
}
if skipResourceFilter && !synthesized {
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
}
logicalFields = filtered
}
conds := make([]string, 0, len(logicalFields))
for _, logical := range logicalFields {
if logical.IsFamily() {
// has/hasAny/hasAll/hasToken are body-JSON only; family members are
// attribute and resource keys, so keep the descriptive error.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}
cond, err := querybuilder.LogicalFamilyCondition(ctx, orgID, startNs, endNs, c.fm, logical, operator, value, sb)
if err != nil {
return nil, nil, err
}
// The same default-exists guard as the single-key path: without it,
// `key = ''` (and LIKE '%', CONTAINS '') would match keyless rows.
if operator.AddDefaultExistsFilter() {
existsCond, err := querybuilder.LogicalFamilyCondition(ctx, orgID, startNs, endNs, c.fm, logical, qbtypes.FilterOperatorExists, nil, sb)
if err != nil {
return nil, nil, err
}
cond = sb.And(cond, existsCond)
}
conds = append(conds, cond)
continue
}
k := logical.Single()
cond, err := c.conditionForKey(ctx, orgID, startNs, endNs, k, operator, value, sb)
if err != nil {
return nil, nil, err
}
conds = append(conds, cond)
if w := c.bodyFullTextDefaultWarning(ctx, orgID, startNs, endNs, k, operator); w != "" {
warnings = append(warnings, w)
}
}
return conds, warnings, nil
return cond, warnings, nil
}
// bodyFullTextDefaultWarning returns the advisory shown when a regexp full-text

View File

@@ -292,39 +292,14 @@ func (m *fieldMapper) ColumnExpressionFor(
}
}
// Group-by/order (String) and aggregation (String/Float64): every candidate is
// exists-guarded and coerced to requiredDataType, in a single multiIf. Raw select
// (Unspecified) keeps the lighter native shape below.
// Group-by/order (String) and aggregation (String/Float64) render through
// the generic coerced column; raw select (Unspecified) keeps the lighter
// native shape below. The schema value carries the body mode, so the flag
// reads one time per call.
if requiredDataType != telemetrytypes.FieldDataTypeUnspecified {
// arrays cannot sit inside Nullable/multiIf, so a lone array candidate stays bare
if len(candidates) == 1 && (strings.Contains(candidates[0].Name, telemetrytypes.ArraySep) ||
strings.Contains(candidates[0].Name, telemetrytypes.ArrayAnyIndex) ||
candidates[0].FieldDataType.IsArray()) {
return m.FieldFor(ctx, orgID, tsStart, tsEnd, candidates[0])
}
var dummyValue any = ""
if requiredDataType == telemetrytypes.FieldDataTypeFloat64 {
dummyValue = 0.0
}
var stmts []string
for _, key := range candidates {
guard, err := m.ExistsFor(ctx, orgID, tsStart, tsEnd, key, true)
if err != nil {
return "", err
}
var fieldExpression string
if key.FieldContext == telemetrytypes.FieldContextBody && !bodyJSONEnabled {
fieldExpression, _ = GetBodyJSONKey(ctx, key, qbtypes.FilterOperatorUnknown, dummyValue)
} else {
fieldExpression, err = m.FieldFor(ctx, orgID, tsStart, tsEnd, key)
if err != nil {
return "", err
}
fieldExpression, _ = querybuilder.DataTypeCollisionHandledFieldName(key, dummyValue, fieldExpression, qbtypes.FilterOperatorUnknown)
}
stmts = append(stmts, guard, fieldExpression)
}
return fmt.Sprintf("multiIf(%s, NULL)", strings.Join(stmts, ", ")), nil
scope := querybuilder.CompileScope{OrgID: orgID, StartNs: tsStart, EndNs: tsEnd}
schema := logsColumnSchema{fm: m, bodyJSONEnabled: bodyJSONEnabled}
return querybuilder.RenderCoercedColumn(ctx, scope, schema, m, querybuilder.WrapAsLogicalFields(field.Name, candidates), requiredDataType)
}
if len(candidates) == 1 {
@@ -377,6 +352,42 @@ func (m *fieldMapper) ColumnExpressionFor(
return fmt.Sprintf("multiIf(%s, NULL)", strings.Join(stmts, ", ")), nil
}
// logsColumnSchema answers the generic coerced-column questions with the
// body mode fixed for one call.
type logsColumnSchema struct {
fm *fieldMapper
bodyJSONEnabled bool
}
// RawRead returns the uncoerced value read of one field; the legacy
// string-body path keeps its own typed read. An array candidate reads bare
// through its own field expression on both body modes. dummyValue only feeds
// the legacy body read and leaves with it.
func (s logsColumnSchema) RawRead(ctx context.Context, scope querybuilder.CompileScope, logical *telemetrytypes.LogicalField, dummyValue any) (string, error) {
key := logical.Single()
if key.FieldContext == telemetrytypes.FieldContextBody && !s.BareCandidate(logical) && !s.bodyJSONEnabled {
fieldExpression, _ := GetBodyJSONKey(ctx, key, qbtypes.FilterOperatorUnknown, dummyValue)
return fieldExpression, nil
}
return querybuilder.LogicalValueExpr(ctx, scope.OrgID, scope.StartNs, scope.EndNs, s.fm, logical)
}
// Uncoerced: the legacy body read carries its own typing, so the stage
// coercion does not apply to it.
func (s logsColumnSchema) Uncoerced(_ context.Context, _ querybuilder.CompileScope, logical *telemetrytypes.LogicalField) (bool, error) {
key := logical.Single()
return key.FieldContext == telemetrytypes.FieldContextBody && !s.bodyJSONEnabled, nil
}
// BareCandidate: arrays cannot sit inside Nullable/multiIf, so a lone array
// candidate stays bare.
func (s logsColumnSchema) BareCandidate(logical *telemetrytypes.LogicalField) bool {
key := logical.Single()
return strings.Contains(key.Name, telemetrytypes.ArraySep) ||
strings.Contains(key.Name, telemetrytypes.ArrayAnyIndex) ||
key.FieldDataType.IsArray()
}
func (m *fieldMapper) CandidateKeys(_ context.Context, _ valuer.UUID, field *telemetrytypes.TelemetryFieldKey, value any, keys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey {
if matches := keys[field.Name]; len(matches) > 0 {
return matches

View File

@@ -170,7 +170,8 @@ func (c *conditionBuilder) conditionFor(
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported operator: %v", operator)
}
// Metrics has no resource sub-query, so options are unused.
// ConditionFor rejects the logs-only function operators and hands the term to
// the generic flow; metrics has no resource sub-query, so options are unused.
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
orgID valuer.UUID,
@@ -179,52 +180,55 @@ func (c *conditionBuilder) ConditionFor(
key *telemetrytypes.TelemetryFieldKey,
logicalFields []*telemetrytypes.LogicalField,
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey,
_ qbtypes.ConditionBuilderOptions,
options qbtypes.ConditionBuilderOptions,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken/search are logs-only functions; reject for metrics.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}
var warnings []string
if len(logicalFields) == 0 {
var keys []*telemetrytypes.TelemetryFieldKey
if _, isColumn := timeSeriesV4Columns[key.Name]; isColumn {
keys = []*telemetrytypes.TelemetryFieldKey{key}
} else {
if len(fieldKeys[key.Name]) == 0 {
warnings = append(warnings, fmt.Sprintf("label `%s` not found in metadata; check the label name for typos", key.Name))
}
keys = []*telemetrytypes.TelemetryFieldKey{
telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextAttribute, key.FieldDataType),
}
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
keys = append(keys, telemetrytypes.NewTelemetryFieldKey(
key.FieldContext.StringValue()+"."+key.Name, telemetrytypes.FieldContextAttribute, key.FieldDataType))
}
}
logicalFields = querybuilder.WrapAsLogicalFields(key.Name, keys)
}
conds := make([]string, 0, len(logicalFields))
for _, logical := range logicalFields {
if logical.IsFamily() {
cond, err := querybuilder.LogicalFamilyCondition(ctx, orgID, startNs, endNs, c.fm, logical, operator, value, sb)
if err != nil {
return nil, nil, err
}
conds = append(conds, cond)
continue
}
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, logical.Single(), operator, value, sb)
if err != nil {
return nil, nil, err
}
conds = append(conds, cond)
}
return conds, warnings, nil
scope := querybuilder.CompileScope{OrgID: orgID, StartNs: startNs, EndNs: endNs}
return querybuilder.CompileTerm(ctx, scope, c, querybuilder.SkipResourceNone, key, logicalFields, fieldKeys, options, operator, value, sb)
}
// AmendEvidence: metrics fold no intrinsic storage into the evidence.
func (c *conditionBuilder) AmendEvidence(_ context.Context, _ querybuilder.CompileScope, _ *telemetrytypes.TelemetryFieldKey, fields []*telemetrytypes.LogicalField) []*telemetrytypes.LogicalField {
return fields
}
// Synthesize: an intrinsic time-series column passes through as itself; any
// other name reads as a label (with the context-prefixed spelling second),
// with an advisory when the metadata has never seen it. Labels always read
// from the JSON, so an unknown name still queries instead of erroring.
func (c *conditionBuilder) Synthesize(_ context.Context, _ querybuilder.CompileScope, key *telemetrytypes.TelemetryFieldKey, _ qbtypes.FilterOperator, _ any, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) ([]*telemetrytypes.LogicalField, []string, error) {
if _, isColumn := timeSeriesV4Columns[key.Name]; isColumn {
return querybuilder.WrapAsLogicalFields(key.Name, []*telemetrytypes.TelemetryFieldKey{key}), nil, nil
}
var warnings []string
if len(fieldKeys[key.Name]) == 0 {
warnings = append(warnings, fmt.Sprintf("label `%s` not found in metadata; check the label name for typos", key.Name))
}
keys := []*telemetrytypes.TelemetryFieldKey{
telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextAttribute, key.FieldDataType),
}
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
keys = append(keys, telemetrytypes.NewTelemetryFieldKey(
key.FieldContext.StringValue()+"."+key.Name, telemetrytypes.FieldContextAttribute, key.FieldDataType))
}
return querybuilder.WrapAsLogicalFields(key.Name, keys), warnings, nil
}
// CompileField: a family compiles through the shared operator switch; a
// single label keeps the metrics operator forms (labels read back as String
// from the labels JSON, so the collision handling differs) and takes no
// exists guard.
func (c *conditionBuilder) CompileField(ctx context.Context, scope querybuilder.CompileScope, logical *telemetrytypes.LogicalField, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, []string, error) {
if logical.IsFamily() {
cond, err := querybuilder.CompileFieldWithSharedOperators(ctx, scope, c.fm, logical, operator, value, sb, false)
return cond, nil, err
}
cond, err := c.conditionFor(ctx, scope.OrgID, scope.StartNs, scope.EndNs, logical.Single(), operator, value, sb)
return cond, nil, err
}

View File

@@ -22,32 +22,12 @@ type conditionBuilder struct {
}
var _ qbtypes.ConditionBuilder = (*conditionBuilder)(nil)
var _ querybuilder.TermSchema = (*conditionBuilder)(nil)
func NewConditionBuilder(fm qbtypes.FieldMapper) *conditionBuilder {
return &conditionBuilder{fm: fm}
}
func (c *conditionBuilder) conditionFor(
ctx context.Context,
orgID valuer.UUID,
startNs uint64,
endNs uint64,
logical *telemetrytypes.LogicalField,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
// TODO(srikanthccv): maybe extend this to every possible attribute
if logical.Name == "duration_nano" || logical.Name == "durationNano" { // QoL improvement
coerced, err := coerceDurationValue(value)
if err != nil {
return "", err
}
value = coerced
}
return querybuilder.LogicalFamilyCondition(ctx, orgID, startNs, endNs, c.fm, logical, operator, value, sb)
}
func coerceDurationValue(value any) (any, error) {
switch v := value.(type) {
case string:
@@ -98,9 +78,9 @@ func candidateLookupKeys(key *telemetrytypes.TelemetryFieldKey, fieldKeys map[st
return nil
}
// ConditionFor resolves the referenced key to the key(s) to filter on (ResolveKeys, else
// synthesized keys with a warning) and builds one condition per resolved key. fieldKeys is
// the full metadata map; the builder owns key resolution.
// ConditionFor rejects the logs-only function operators and hands the term to
// the generic flow; the traces-specific behavior lives in the TermSchema
// methods below.
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
orgID valuer.UUID,
@@ -114,121 +94,83 @@ func (c *conditionBuilder) ConditionFor(
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
// has/hasAny/hasAll/hasToken/search are logs-only functions; reject for traces.
if err := querybuilder.NewFunctionUnsupportedError(operator); err != nil {
return nil, nil, err
}
matches := logicalFields
skipResourceFilter := options.SkipResourceFilter
logicalFields, warning := querybuilder.ResolveLogicalFields(key, matches)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
// 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(logicalFields) > 0 {
hasColumn := false
for _, logical := range logicalFields {
if logical.FieldContext == telemetrytypes.FieldContextSpan {
hasColumn = true
break
}
}
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.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)
}
}
logicalFields = combined
}
}
}
synthesized := false
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.
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 fields from the main
// query. Synthesized keys are exempt: the sub-query skips keys absent from metadata.
if skipResourceFilter && !synthesized {
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
}
logicalFields = filtered
}
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
}
conds = append(conds, cond)
}
return conds, warnings, nil
scope := querybuilder.CompileScope{OrgID: orgID, StartNs: startNs, EndNs: endNs}
return querybuilder.CompileTerm(ctx, scope, c, querybuilder.SkipResourceDrop, key, logicalFields, fieldKeys, options, operator, value, sb)
}
func (c *conditionBuilder) conditionForLogicalField(
ctx context.Context,
orgID valuer.UUID,
startNs uint64,
endNs uint64,
logical *telemetrytypes.LogicalField,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
// AmendEvidence: 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).
func (c *conditionBuilder) AmendEvidence(ctx context.Context, scope querybuilder.CompileScope, key *telemetrytypes.TelemetryFieldKey, fields []*telemetrytypes.LogicalField) []*telemetrytypes.LogicalField {
if key.FieldContext != telemetrytypes.FieldContextUnspecified || len(fields) == 0 {
return fields
}
for _, logical := range fields {
if logical.FieldContext == telemetrytypes.FieldContextSpan {
return fields
}
}
probe := telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextSpan, key.FieldDataType)
cols, colErr := c.fm.ColumnFor(ctx, scope.OrgID, scope.StartNs, scope.EndNs, probe)
if colErr != nil || len(cols) == 0 {
return fields
}
combined := make([]*telemetrytypes.LogicalField, 0, len(fields)+1)
combined = append(combined, telemetrytypes.SingleLogicalField(key.Name, probe))
for _, logical := range fields {
if columnMatchesDataType(cols[0], logical.FieldDataType) {
combined = append(combined, logical)
}
}
return combined
}
// Synthesize: 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.
func (c *conditionBuilder) Synthesize(ctx context.Context, scope querybuilder.CompileScope, key *telemetrytypes.TelemetryFieldKey, _ qbtypes.FilterOperator, value any, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) ([]*telemetrytypes.LogicalField, []string, error) {
fields := querybuilder.WrapAsLogicalFields(key.Name, c.fm.CandidateKeys(ctx, scope.OrgID, key, value, candidateLookupKeys(key, fieldKeys)))
if len(fields) == 0 {
return nil, nil, querybuilder.NewKeyNotFoundError(key.Name)
}
return fields, []string{querybuilder.NewKeyNotFoundWarning(key.Name)}, nil
}
// CompileField: span-scope names build their own predicates; everything else compiles
// through the shared operator switch (with the duration coercion in front), and the
// default exists guard applies except to intrinsic columns, which always exist.
func (c *conditionBuilder) CompileField(ctx context.Context, scope querybuilder.CompileScope, logical *telemetrytypes.LogicalField, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, []string, error) {
if c.isSpanScopeField(logical.Name) {
return c.buildSpanScopeCondition(logical.Single(), operator, value, startNs)
cond, err := c.buildSpanScopeCondition(logical.Single(), operator, value, scope.StartNs)
return cond, nil, err
}
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, logical.Single())
if slices.Contains(maps.Keys(IntrinsicFields), field) ||
slices.Contains(maps.Keys(IntrinsicFieldsDeprecated), field) ||
slices.Contains(maps.Keys(CalculatedFields), field) ||
slices.Contains(maps.Keys(CalculatedFieldsDeprecated), field) {
return condition, nil
}
existsCondition, err := c.conditionFor(ctx, orgID, startNs, endNs, logical, qbtypes.FilterOperatorExists, nil, sb)
// TODO(srikanthccv): maybe extend this to every possible attribute
if logical.Name == "duration_nano" || logical.Name == "durationNano" { // QoL improvement
coerced, err := coerceDurationValue(value)
if err != nil {
return "", err
return "", nil, err
}
return sb.And(condition, existsCondition), nil
value = coerced
}
return condition, nil
cond, err := querybuilder.CompileFieldWithSharedOperators(ctx, scope, c.fm, logical, operator, value, sb, !c.isIntrinsic(ctx, scope, logical))
return cond, nil, err
}
// isIntrinsic reports whether the field reads an always-present table column,
// which needs no exists guard.
func (c *conditionBuilder) isIntrinsic(ctx context.Context, scope querybuilder.CompileScope, logical *telemetrytypes.LogicalField) bool {
field, _ := c.fm.FieldFor(ctx, scope.OrgID, scope.StartNs, scope.EndNs, logical.Single())
return slices.Contains(maps.Keys(IntrinsicFields), field) ||
slices.Contains(maps.Keys(IntrinsicFieldsDeprecated), field) ||
slices.Contains(maps.Keys(CalculatedFields), field) ||
slices.Contains(maps.Keys(CalculatedFieldsDeprecated), field)
}
func (c *conditionBuilder) isSpanScopeField(name string) bool {

View File

@@ -442,34 +442,12 @@ func (m *fieldMapper) ColumnExpressionFor(
return "", err
}
// Group-by/order (String) and aggregation (String/Float64): every candidate is
// exists-guarded and coerced to requiredDataType, in a single multiIf. Raw select
// (Unspecified) keeps the lighter native shape below.
// Group-by/order (String) and aggregation (String/Float64) render through
// the generic coerced column; raw select (Unspecified) keeps the lighter
// native shape below.
if requiredDataType != telemetrytypes.FieldDataTypeUnspecified {
var dummyValue any = ""
if requiredDataType == telemetrytypes.FieldDataTypeFloat64 {
dummyValue = 0.0
}
stmts := make([]string, 0, len(candidates)*2)
for _, logical := range candidates {
value, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, m, logical)
if err != nil {
return "", err
}
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.logicalIsTemporal(ctx, startNs, endNs, logical); err != nil {
return "", err
} else if !temporal {
coerced, _ = querybuilder.DataTypeCollisionHandledFieldName(logical.Single(), dummyValue, value, qbtypes.FilterOperatorUnknown)
}
stmts = append(stmts, guard, coerced)
}
return fmt.Sprintf("multiIf(%s, NULL)", strings.Join(stmts, ", ")), nil
scope := querybuilder.CompileScope{OrgID: orgID, StartNs: startNs, EndNs: endNs}
return querybuilder.RenderCoercedColumn(ctx, scope, m, m, candidates, requiredDataType)
}
if len(candidates) == 1 {
@@ -506,6 +484,23 @@ func (m *fieldMapper) ColumnExpressionFor(
return fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", ")), nil
}
// RawRead returns the uncoerced value read of one field for the generic
// coerced column: the merged read for a family, the member's own read
// otherwise.
func (m *fieldMapper) RawRead(ctx context.Context, scope querybuilder.CompileScope, logical *telemetrytypes.LogicalField, _ any) (string, error) {
return querybuilder.LogicalValueExpr(ctx, scope.OrgID, scope.StartNs, scope.EndNs, m, logical)
}
// Uncoerced: a time column keeps its native type; coercing it would yield seconds.
func (m *fieldMapper) Uncoerced(ctx context.Context, scope querybuilder.CompileScope, logical *telemetrytypes.LogicalField) (bool, error) {
return m.logicalIsTemporal(ctx, scope.StartNs, scope.EndNs, logical)
}
// BareCandidate: traces has no array-typed candidates.
func (m *fieldMapper) BareCandidate(_ *telemetrytypes.LogicalField) bool {
return false
}
// 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) {