mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
cloud renamed its composition verb to zip own word, and this module publishes the other half of that seam.
1778 lines
59 KiB
Go
1778 lines
59 KiB
Go
package querybuilder
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/antlr4-go/antlr/v4"
|
|
sqlbuilder "github.com/hanzo-ds/sqlbuilder"
|
|
grammar "github.com/hanzoai/o11y/pkg/parser/filterquery/grammar"
|
|
qbtypes "github.com/hanzoai/o11y/pkg/types/querybuildertypes/querybuildertypesv5"
|
|
"github.com/hanzoai/o11y/pkg/types/telemetrytypes"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// TestPrepareWhereClause_EmptyVariableList ensures PrepareWhereClause errors when a variable has an empty list value.
|
|
func TestPrepareWhereClause_EmptyVariableList(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
variables map[string]qbtypes.VariableItem
|
|
expectError bool
|
|
wantInError string
|
|
}{
|
|
{
|
|
name: "Empty []any for equality",
|
|
expr: "service = $service",
|
|
variables: map[string]qbtypes.VariableItem{
|
|
"service": {Value: []any{}},
|
|
},
|
|
expectError: true,
|
|
wantInError: "Found 1 errors while parsing the search expression",
|
|
},
|
|
{
|
|
name: "Empty []any for IN clause",
|
|
expr: "service IN $service",
|
|
variables: map[string]qbtypes.VariableItem{
|
|
"service": {Value: []any{}},
|
|
},
|
|
expectError: true,
|
|
wantInError: "Found 1 errors while parsing the search expression",
|
|
},
|
|
}
|
|
|
|
keys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"service": {
|
|
{
|
|
Name: "service",
|
|
Signal: telemetrytypes.SignalLogs,
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
opts := FilterExprVisitorOpts{
|
|
Context: context.Background(),
|
|
FieldKeys: keys,
|
|
Variables: tt.variables,
|
|
}
|
|
|
|
_, err := PrepareWhereClause(tt.expr, opts)
|
|
|
|
if tt.expectError {
|
|
if err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
if tt.wantInError != "" && !strings.Contains(err.Error(), tt.wantInError) {
|
|
t.Fatalf("expected error to contain %q, got %q", tt.wantInError, err.Error())
|
|
}
|
|
} else if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// createTestVisitor creates a filterExpressionVisitor for testing VisitKey.
|
|
func createTestVisitor(t *testing.T, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey, ignoreNotFoundKeys bool) *filterExpressionVisitor {
|
|
t.Helper()
|
|
return &filterExpressionVisitor{
|
|
context: t.Context(),
|
|
logger: slog.Default(),
|
|
fieldKeys: fieldKeys,
|
|
ignoreNotFoundKeys: ignoreNotFoundKeys,
|
|
keysWithWarnings: make(map[string]bool),
|
|
builder: sqlbuilder.NewSelectBuilder(),
|
|
}
|
|
}
|
|
|
|
// parseKeyContext parses a key string and returns the KeyContext for testing.
|
|
func parseKeyContext(keyText string) *grammar.KeyContext {
|
|
input := antlr.NewInputStream(keyText + " = 1") // Add minimal expression to parse key
|
|
lexer := grammar.NewFilterQueryLexer(input)
|
|
tokens := antlr.NewCommonTokenStream(lexer, 0)
|
|
parser := grammar.NewFilterQueryParser(tokens)
|
|
tree := parser.Query()
|
|
|
|
// Navigate to the key context
|
|
if tree.Expression() != nil {
|
|
if orExpr := tree.Expression().OrExpression(); orExpr != nil {
|
|
if andExprs := orExpr.AllAndExpression(); len(andExprs) > 0 {
|
|
if unaryExprs := andExprs[0].AllUnaryExpression(); len(unaryExprs) > 0 {
|
|
if primary := unaryExprs[0].Primary(); primary != nil {
|
|
if comparison := primary.Comparison(); comparison != nil {
|
|
if keyCtx, ok := comparison.Key().(*grammar.KeyContext); ok {
|
|
return keyCtx
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestVisitKey tests the VisitKey method of filterExpressionVisitor.
|
|
func TestVisitKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
keyText string
|
|
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
|
|
ignoreNotFoundKeys bool
|
|
expectedKeys []telemetrytypes.TelemetryFieldKey
|
|
expectedErrors []string
|
|
expectedMainErrURL string
|
|
expectedWarnings []string
|
|
expectedMainWrnURL string
|
|
}{
|
|
// Basic key lookup tests
|
|
{
|
|
name: "Simple key found",
|
|
keyText: "service",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"service": {
|
|
{
|
|
Name: "service",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
},
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "service",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
expectedErrors: nil,
|
|
expectedMainErrURL: "",
|
|
expectedWarnings: nil,
|
|
expectedMainWrnURL: "",
|
|
},
|
|
{
|
|
name: "Key not found",
|
|
keyText: "unknown_key",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"service": []*telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "service",
|
|
Signal: telemetrytypes.SignalLogs,
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
},
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{},
|
|
expectedErrors: []string{"key `unknown_key` not found"},
|
|
expectedMainErrURL: "https://o11y.io/docs/userguide/search-troubleshooting/#key-fieldname-not-found",
|
|
expectedWarnings: nil,
|
|
expectedMainWrnURL: "",
|
|
},
|
|
{
|
|
name: "Multiple keys with same name different contexts",
|
|
keyText: "service.name",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"service.name": {
|
|
{
|
|
Name: "service.name",
|
|
FieldContext: telemetrytypes.FieldContextResource,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
{
|
|
Name: "service.name",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
},
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "service.name",
|
|
FieldContext: telemetrytypes.FieldContextResource,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
expectedErrors: nil,
|
|
expectedMainErrURL: "",
|
|
expectedWarnings: []string{"ambiguous"},
|
|
expectedMainWrnURL: "https://o11y.io/docs/userguide/field-context-data-types/",
|
|
},
|
|
// Context prefixed keys tests
|
|
{
|
|
name: "Attribute prefixed key",
|
|
keyText: "attribute.user_id",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"user_id": {
|
|
{
|
|
Name: "user_id",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
},
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "user_id",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
expectedErrors: nil,
|
|
expectedMainErrURL: "",
|
|
expectedWarnings: nil,
|
|
expectedMainWrnURL: "",
|
|
},
|
|
{
|
|
name: "Resource prefixed key",
|
|
keyText: "resource.service.name",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"service.name": {
|
|
{
|
|
Name: "service.name",
|
|
FieldContext: telemetrytypes.FieldContextResource,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
},
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "service.name",
|
|
FieldContext: telemetrytypes.FieldContextResource,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
expectedErrors: nil,
|
|
expectedMainErrURL: "",
|
|
expectedWarnings: nil,
|
|
expectedMainWrnURL: "",
|
|
},
|
|
{
|
|
name: "Attribute prefix filters to attribute context only",
|
|
keyText: "attribute.host",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"host": {
|
|
{
|
|
Name: "host",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
{
|
|
Name: "host",
|
|
FieldContext: telemetrytypes.FieldContextResource,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
},
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "host",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
expectedErrors: nil,
|
|
expectedMainErrURL: "",
|
|
expectedWarnings: nil,
|
|
expectedMainWrnURL: "",
|
|
},
|
|
// Data type suffix tests
|
|
{
|
|
name: "String type suffix",
|
|
keyText: "status:string",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"status": {
|
|
{
|
|
Name: "status",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
{
|
|
Name: "status",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeInt64,
|
|
},
|
|
},
|
|
},
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "status",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
expectedErrors: nil,
|
|
expectedMainErrURL: "",
|
|
expectedWarnings: nil,
|
|
expectedMainWrnURL: "",
|
|
},
|
|
{
|
|
name: "Number type suffix (int64 maps to Number)",
|
|
keyText: "count:int64",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"count": {
|
|
{
|
|
Name: "count",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeInt64,
|
|
},
|
|
},
|
|
},
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "count",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeInt64,
|
|
},
|
|
},
|
|
expectedErrors: nil,
|
|
expectedMainErrURL: "",
|
|
expectedWarnings: nil,
|
|
expectedMainWrnURL: "",
|
|
},
|
|
// IgnoreNotFoundKeys tests
|
|
{
|
|
name: "Unknown key with ignoreNotFoundKeys=false",
|
|
keyText: "unknown_key",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"service": []*telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "service",
|
|
Signal: telemetrytypes.SignalLogs,
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
},
|
|
ignoreNotFoundKeys: false,
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{},
|
|
expectedErrors: []string{"key `unknown_key` not found"},
|
|
expectedMainErrURL: "https://o11y.io/docs/userguide/search-troubleshooting/#key-fieldname-not-found",
|
|
expectedWarnings: nil,
|
|
expectedMainWrnURL: "",
|
|
},
|
|
{
|
|
name: "Unknown key with ignoreNotFoundKeys=true",
|
|
keyText: "unknown_key",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"service": []*telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "service",
|
|
Signal: telemetrytypes.SignalLogs,
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
},
|
|
ignoreNotFoundKeys: true,
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{},
|
|
expectedErrors: nil,
|
|
expectedMainErrURL: "",
|
|
expectedWarnings: nil,
|
|
expectedMainWrnURL: "",
|
|
},
|
|
// Ambiguous keys tests
|
|
{
|
|
name: "Ambiguous key with resource and attribute",
|
|
keyText: "host",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"host": {
|
|
{
|
|
Name: "host",
|
|
FieldContext: telemetrytypes.FieldContextResource,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
{
|
|
Name: "host",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
},
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "host",
|
|
FieldContext: telemetrytypes.FieldContextResource,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
expectedErrors: nil,
|
|
expectedMainErrURL: "",
|
|
expectedWarnings: []string{"ambiguous"},
|
|
expectedMainWrnURL: "https://o11y.io/docs/userguide/field-context-data-types/",
|
|
},
|
|
{
|
|
name: "Ambiguous key with different data types",
|
|
keyText: "status_ambiguous",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"status_ambiguous": {
|
|
{
|
|
Name: "status_ambiguous",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
{
|
|
Name: "status_ambiguous",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeInt64,
|
|
},
|
|
},
|
|
},
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "status_ambiguous",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
{
|
|
Name: "status_ambiguous",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeInt64,
|
|
},
|
|
},
|
|
expectedErrors: nil,
|
|
expectedMainErrURL: "",
|
|
expectedWarnings: []string{"ambiguous"},
|
|
expectedMainWrnURL: "https://o11y.io/docs/userguide/field-context-data-types/",
|
|
},
|
|
// These 3 unit tests have both attibute.custom_field and custom_field in the map
|
|
{
|
|
name: "only custom_field is selected",
|
|
keyText: "custom_field",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"attribute.custom_field": {
|
|
{
|
|
Name: "attribute.custom_field",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
"custom_field": {
|
|
{
|
|
Name: "custom_field",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
},
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "custom_field",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
expectedErrors: nil,
|
|
expectedMainErrURL: "",
|
|
expectedWarnings: nil,
|
|
expectedMainWrnURL: "",
|
|
},
|
|
{
|
|
name: "only attribute.custom_field is selected",
|
|
keyText: "attribute.attribute.custom_field",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"attribute.custom_field": {
|
|
{
|
|
Name: "attribute.custom_field",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
"custom_field": {
|
|
{
|
|
Name: "custom_field",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
},
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "attribute.custom_field",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
expectedErrors: nil,
|
|
expectedMainErrURL: "",
|
|
expectedWarnings: nil,
|
|
expectedMainWrnURL: "",
|
|
},
|
|
{
|
|
name: "both custom_field and attribute.custom_field are selected",
|
|
keyText: "attribute.custom_field",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"attribute.custom_field": {
|
|
{
|
|
Name: "attribute.custom_field",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
"custom_field": {
|
|
{
|
|
Name: "custom_field",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
},
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "attribute.custom_field",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
{
|
|
Name: "custom_field",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
expectedErrors: nil,
|
|
expectedMainErrURL: "",
|
|
expectedWarnings: []string{"ambiguous"},
|
|
expectedMainWrnURL: "https://o11y.io/docs/userguide/field-context-data-types/",
|
|
},
|
|
// Resource attribute ambiguity - resource context is preferred
|
|
{
|
|
name: "Resource context preferred over attribute for ambiguous key",
|
|
keyText: "deployment.environment",
|
|
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"deployment.environment": {
|
|
{
|
|
Name: "deployment.environment",
|
|
FieldContext: telemetrytypes.FieldContextResource,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
{
|
|
Name: "deployment.environment",
|
|
FieldContext: telemetrytypes.FieldContextAttribute,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
},
|
|
expectedKeys: []telemetrytypes.TelemetryFieldKey{
|
|
{
|
|
Name: "deployment.environment",
|
|
FieldContext: telemetrytypes.FieldContextResource,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
},
|
|
},
|
|
expectedErrors: nil,
|
|
expectedMainErrURL: "",
|
|
expectedWarnings: []string{"ambiguous", "attribute.deployment.environment"},
|
|
expectedMainWrnURL: "https://o11y.io/docs/userguide/field-context-data-types/",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
visitor := createTestVisitor(t, tt.fieldKeys, tt.ignoreNotFoundKeys)
|
|
keyCtx := parseKeyContext(tt.keyText)
|
|
|
|
if keyCtx == nil {
|
|
t.Fatalf("failed to parse key context for %q", tt.keyText)
|
|
}
|
|
|
|
result := visitor.VisitKey(keyCtx)
|
|
keys, ok := result.([]*telemetrytypes.TelemetryFieldKey)
|
|
|
|
if !ok {
|
|
t.Fatalf("expected []*TelemetryFieldKey, got %T", result)
|
|
}
|
|
|
|
// Check expected keys count
|
|
if len(keys) != len(tt.expectedKeys) {
|
|
t.Errorf("expected %d keys, got %d", len(tt.expectedKeys), len(keys))
|
|
}
|
|
|
|
// Check each expected key matches name, field context, and data type
|
|
for _, expectedKey := range tt.expectedKeys {
|
|
found := false
|
|
for _, key := range keys {
|
|
if key.Name == expectedKey.Name &&
|
|
key.FieldContext == expectedKey.FieldContext &&
|
|
key.FieldDataType == expectedKey.FieldDataType {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected key {Name: %q, FieldContext: %v, FieldDataType: %v} not found in results: %v",
|
|
expectedKey.Name, expectedKey.FieldContext, expectedKey.FieldDataType, keys)
|
|
}
|
|
}
|
|
|
|
// Check errors
|
|
if tt.expectedErrors != nil {
|
|
if len(visitor.errors) != len(tt.expectedErrors) {
|
|
t.Errorf("expected %d errors, got %d: %v", len(tt.expectedErrors), len(visitor.errors), visitor.errors)
|
|
}
|
|
for _, expectedError := range tt.expectedErrors {
|
|
found := false
|
|
for _, err := range visitor.errors {
|
|
if strings.Contains(err, expectedError) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected error containing %q, got errors: %v", expectedError, visitor.errors)
|
|
}
|
|
}
|
|
} else {
|
|
if len(visitor.errors) != 0 {
|
|
t.Errorf("expected no errors, got %d: %v", len(visitor.errors), visitor.errors)
|
|
}
|
|
}
|
|
|
|
// Check mainErrorURL
|
|
if visitor.mainErrorURL != tt.expectedMainErrURL {
|
|
t.Errorf("expected mainErrorURL %q, got %q", tt.expectedMainErrURL, visitor.mainErrorURL)
|
|
}
|
|
|
|
// Check warnings
|
|
if tt.expectedWarnings != nil {
|
|
for _, expectedWarn := range tt.expectedWarnings {
|
|
found := false
|
|
for _, warn := range visitor.warnings {
|
|
if strings.Contains(strings.ToLower(warn), strings.ToLower(expectedWarn)) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected warning containing %q, got warnings: %v", expectedWarn, visitor.warnings)
|
|
}
|
|
}
|
|
} else {
|
|
if len(visitor.warnings) != 0 {
|
|
t.Errorf("expected no warnings, got %d: %v", len(visitor.warnings), visitor.warnings)
|
|
}
|
|
}
|
|
|
|
// Check mainWarnURL
|
|
if visitor.mainWarnURL != tt.expectedMainWrnURL {
|
|
t.Errorf("expected mainWarnURL %q, got %q", tt.expectedMainWrnURL, visitor.mainWarnURL)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TestVisitComparison
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// This suite exercises the visitor with two different configurations
|
|
// side-by-side for each expression, asserting both expected outputs:
|
|
//
|
|
// resourceConditionBuilder (wantRSB) — returns TrueConditionLiteral for
|
|
// non-resource keys and "{name}_cond" for resource keys (x, y, z).
|
|
// Opts: SkipFullTextFilter:true, SkipFunctionCalls:true, IgnoreNotFoundKeys:true.
|
|
//
|
|
// conditionBuilder (wantSB) — returns "{name}_cond" for every key regardless
|
|
// of FieldContext. Opts: SkipFullTextFilter:false, SkipFunctionCalls:false,
|
|
// IgnoreNotFoundKeys:false, FullTextColumn:bodyCol.
|
|
//
|
|
// Key behavioral rules:
|
|
//
|
|
// resourceConditionBuilder:
|
|
// attr key (a,b,c) → SkipConditionLiteral (no resource match)
|
|
// resource key (x,y,z) → "{name}_cond"
|
|
// full-text / function call → SkipConditionLiteral (skipped)
|
|
// unknown key → SkipConditionLiteral (ignored; IgnoreNotFoundKeys=true)
|
|
//
|
|
// conditionBuilder:
|
|
// any key (a,b,c,x,y,z) → "{name}_cond"
|
|
// full-text ('hello') → "body_cond" (bodyCol.Name="body")
|
|
// function call (non-body) → error (only body JSON search supported)
|
|
// unknown key → error (IgnoreNotFoundKeys=false)
|
|
//
|
|
// __all__ variable (IN/NOT IN $service where service="__all__"):
|
|
// → SkipConditionLiteral in both configurations
|
|
// (PrepareWhereClause converts the final SkipConditionLiteral to TrueConditionLiteral)
|
|
//
|
|
// SkipConditionLiteral propagation rules (both configs):
|
|
// • In AND: filtered out as no-op; if ALL branches are SkipConditionLiteral
|
|
// → AND returns SkipConditionLiteral which propagates upward
|
|
// • In OR: short-circuits the entire OR immediately (returns SkipConditionLiteral)
|
|
// • NOT(SkipConditionLiteral) → SkipConditionLiteral (guard in VisitUnaryExpression)
|
|
// • PrepareWhereClause converts a top-level SkipConditionLiteral to nil
|
|
//
|
|
// Test cases with wantErrSB=true use PrepareWhereClause directly to verify
|
|
// that SB returns an error (instead of calling buildSQLOpts which fatalf's).
|
|
|
|
// "a", "b", "c" are attribute-context fields; "x", "y", "z" are resource-context field.
|
|
var visitTestKeys = map[string][]*telemetrytypes.TelemetryFieldKey{
|
|
"a": {{Name: "a", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
|
"b": {{Name: "b", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
|
"c": {{Name: "c", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
|
"x": {{Name: "x", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
|
"y": {{Name: "y", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
|
"z": {{Name: "z", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
|
"ax": {{Name: "ax", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber},
|
|
{Name: "ax", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
|
"by": {{Name: "by", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber},
|
|
{Name: "by", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
|
"cz": {{Name: "cz", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber},
|
|
{Name: "cz", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
|
}
|
|
|
|
type resourceConditionBuilder struct{}
|
|
|
|
func (b *resourceConditionBuilder) ConditionFor(
|
|
_ context.Context,
|
|
_ uint64,
|
|
_ uint64,
|
|
key *telemetrytypes.TelemetryFieldKey,
|
|
_ qbtypes.FilterOperator,
|
|
_ any,
|
|
_ *sqlbuilder.SelectBuilder,
|
|
) (string, error) {
|
|
|
|
if key.FieldContext != telemetrytypes.FieldContextResource {
|
|
return SkipConditionLiteral, nil
|
|
}
|
|
|
|
return fmt.Sprintf("%s_cond", key.Name), nil
|
|
}
|
|
|
|
type conditionBuilder struct{}
|
|
|
|
func (b *conditionBuilder) ConditionFor(
|
|
_ context.Context,
|
|
_ uint64,
|
|
_ uint64,
|
|
key *telemetrytypes.TelemetryFieldKey,
|
|
_ qbtypes.FilterOperator,
|
|
_ any,
|
|
_ *sqlbuilder.SelectBuilder,
|
|
) (string, error) {
|
|
|
|
return fmt.Sprintf("%s_cond", key.Name), nil
|
|
}
|
|
|
|
// visitComparisonCase is a single test case for the TestVisitComparison_* family.
|
|
// Each case is run under two independent configurations:
|
|
//
|
|
// - rsbOpts (resourceConditionBuilder): skips full-text and function calls,
|
|
// ignores unknown keys, produces conditions only for resource-context keys.
|
|
//
|
|
// - sbOpts (conditionBuilder): skips resource-context keys (unless OR is present),
|
|
// evaluates full-text, errors on unknown keys.
|
|
type visitComparisonCase struct {
|
|
name string
|
|
expr string
|
|
wantRSB string // expected SQL from resourceConditionBuilder
|
|
wantSB string // expected SQL from conditionBuilder
|
|
wantErrSB bool // when true, conditionBuilder is expected to return an error
|
|
wantErrRSB bool // when true, resourceConditionBuilder is expected to return an error
|
|
}
|
|
|
|
// visitComparisonOpts builds the two FilterExprVisitorOpts shared by all
|
|
// TestVisitComparison_* tests.
|
|
func visitComparisonOpts(t *testing.T) (rsbOpts, sbOpts FilterExprVisitorOpts) {
|
|
t.Helper()
|
|
allVariable := map[string]qbtypes.VariableItem{
|
|
"service": {
|
|
Type: qbtypes.DynamicVariableType,
|
|
Value: "__all__",
|
|
},
|
|
}
|
|
// bodyCol is the full-text column; conditionBuilder returns "body_cond" for it.
|
|
bodyCol := &telemetrytypes.TelemetryFieldKey{
|
|
Name: "body",
|
|
FieldContext: telemetrytypes.FieldContextResource,
|
|
FieldDataType: telemetrytypes.FieldDataTypeString,
|
|
}
|
|
rsbOpts = FilterExprVisitorOpts{
|
|
Context: t.Context(),
|
|
FieldKeys: visitTestKeys,
|
|
ConditionBuilder: &resourceConditionBuilder{},
|
|
Variables: allVariable,
|
|
SkipResourceFilter: false,
|
|
SkipFullTextFilter: true,
|
|
SkipFunctionCalls: true,
|
|
IgnoreNotFoundKeys: true,
|
|
}
|
|
sbOpts = FilterExprVisitorOpts{
|
|
Context: t.Context(),
|
|
FieldKeys: visitTestKeys,
|
|
ConditionBuilder: &conditionBuilder{},
|
|
Variables: allVariable,
|
|
SkipResourceFilter: true,
|
|
SkipFullTextFilter: false,
|
|
SkipFunctionCalls: false,
|
|
IgnoreNotFoundKeys: false,
|
|
FullTextColumn: bodyCol,
|
|
}
|
|
return
|
|
}
|
|
|
|
// TestVisitComparison_AND covers AND expressions with attribute keys (a, b, c →
|
|
// TrueConditionLiteral in RSB) and resource keys (x, y, z → "{name}_cond" in RSB).
|
|
func TestVisitComparison_AND(t *testing.T) {
|
|
rsbOpts, sbOpts := visitComparisonOpts(t)
|
|
tests := []visitComparisonCase{
|
|
{
|
|
name: "single attribute key",
|
|
expr: "a = 'v'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE a_cond",
|
|
},
|
|
{
|
|
name: "single resource key",
|
|
expr: "x = 'x'",
|
|
wantRSB: "WHERE x_cond",
|
|
wantSB: "",
|
|
},
|
|
{
|
|
// RSB: both attribute keys → true; AND propagates TrueConditionLiteral.
|
|
name: "two attribute keys AND",
|
|
expr: "a = 'a' AND b = 'b'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (a_cond AND b_cond)",
|
|
},
|
|
{
|
|
name: "two resource keys AND",
|
|
expr: "x = 'x' AND y = 'y'",
|
|
wantRSB: "WHERE (x_cond AND y_cond)",
|
|
wantSB: "",
|
|
},
|
|
{
|
|
// RSB: attribute → true stripped by AND; resource key survives.
|
|
name: "attribute AND resource",
|
|
expr: "a = 'a' AND x = 'x'",
|
|
wantRSB: "WHERE x_cond",
|
|
wantSB: "WHERE a_cond",
|
|
},
|
|
{
|
|
// RSB: a, b → true stripped; x_cond remains.
|
|
name: "three mixed keys AND",
|
|
expr: "a = 'a' AND b = 'b' AND x = 'x'",
|
|
wantRSB: "WHERE x_cond",
|
|
wantSB: "WHERE (a_cond AND b_cond)",
|
|
},
|
|
{
|
|
// RSB: a, b → true stripped; x, y survive.
|
|
name: "four mixed keys in AND",
|
|
expr: "a = 'a' AND b = 'b' AND x = 'x' AND y = 'y'",
|
|
wantRSB: "WHERE (x_cond AND y_cond)",
|
|
wantSB: "WHERE (a_cond AND b_cond)",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := PrepareWhereClause(tt.expr, rsbOpts)
|
|
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
|
}
|
|
result, err = PrepareWhereClause(tt.expr, sbOpts)
|
|
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestVisitComparison_NOT covers the NOT operator in two positions:
|
|
// - unary NOT wrapping a comparison: structural NOT emitted by the visitor.
|
|
// - NOT inside a comparison (e.g. NOT LIKE, NOT EXISTS): the inner NOT is folded
|
|
// into the operator token; conditionBuilder ignores it, so no extra NOT is emitted.
|
|
func TestVisitComparison_NOT(t *testing.T) {
|
|
rsbOpts, sbOpts := visitComparisonOpts(t)
|
|
tests := []visitComparisonCase{
|
|
{
|
|
// Unary NOT on an attribute key: NOT(SkipConditionLiteral) → SkipConditionLiteral (guard).
|
|
name: "NOT attribute key",
|
|
expr: "NOT a = 'a'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE NOT (a_cond)",
|
|
},
|
|
{
|
|
name: "NOT resource key",
|
|
expr: "NOT x = 'x'",
|
|
wantRSB: "WHERE NOT (x_cond)",
|
|
wantSB: "",
|
|
},
|
|
{
|
|
// RSB: NOT(SkipConditionLiteral) → SkipConditionLiteral; stripped from AND; x_cond survives.
|
|
name: "NOT attribute AND resource",
|
|
expr: "NOT a = 'a' AND x = 'x'",
|
|
wantRSB: "WHERE x_cond",
|
|
wantSB: "WHERE NOT (a_cond)",
|
|
},
|
|
{
|
|
// NOT inside comparison (op=NotLike): conditionBuilder ignores it → same as LIKE.
|
|
name: "NOT inside LIKE comparison",
|
|
expr: "a NOT LIKE '%a%'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE a_cond",
|
|
},
|
|
{
|
|
// Unary NOT wrapping LIKE: structural NOT emitted around a_cond.
|
|
name: "NOT at unary level wrapping LIKE",
|
|
expr: "NOT a LIKE '%a%'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE NOT (a_cond)",
|
|
},
|
|
{
|
|
// NOT inside comparison on resource key in OR: no outer NOT, builder ignores inner op.
|
|
name: "NOT inside LIKE OR resource",
|
|
expr: "x NOT LIKE '%x%' OR y = 'y'",
|
|
wantRSB: "WHERE (x_cond OR y_cond)",
|
|
wantSB: "WHERE (x_cond OR y_cond)",
|
|
},
|
|
{
|
|
name: "NOT at unary level wrapping LIKE OR resource",
|
|
expr: "NOT x LIKE '%x%' OR y = 'y'",
|
|
wantRSB: "WHERE (NOT (x_cond) OR y_cond)",
|
|
wantSB: "WHERE (NOT (x_cond) OR y_cond)",
|
|
},
|
|
{
|
|
// RSB: a, b → true stripped; NOT(x_cond) remains.
|
|
name: "three NOTs in AND",
|
|
expr: "NOT a = 'a' AND NOT b = 'b' AND NOT x = 'x'",
|
|
wantRSB: "WHERE NOT (x_cond)",
|
|
wantSB: "WHERE (NOT (a_cond) AND NOT (b_cond))",
|
|
},
|
|
{
|
|
// Unary NOT wraps a comparison that itself has NOT (op=NotLike).
|
|
// The inner NOT is an operator token; the outer NOT is structural.
|
|
name: "unary NOT wrapping comparison NOT LIKE",
|
|
expr: "NOT (a NOT LIKE '%a%')",
|
|
wantRSB: "",
|
|
wantSB: "WHERE NOT ((a_cond))",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := PrepareWhereClause(tt.expr, rsbOpts)
|
|
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
|
}
|
|
result, err = PrepareWhereClause(tt.expr, sbOpts)
|
|
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestVisitComparison_OR covers OR expressions. PrepareWhereClause auto-resets
|
|
// SkipResourceFilter to false when an OR token is detected in the expression,
|
|
// so resource keys become visible in sbOpts for all cases in this suite.
|
|
func TestVisitComparison_OR(t *testing.T) {
|
|
rsbOpts, sbOpts := visitComparisonOpts(t)
|
|
tests := []visitComparisonCase{
|
|
{
|
|
name: "resource OR resource",
|
|
expr: "x = 'x' OR y = 'y'",
|
|
wantRSB: "WHERE (x_cond OR y_cond)",
|
|
wantSB: "WHERE (x_cond OR y_cond)",
|
|
},
|
|
{
|
|
name: "three resource keys OR",
|
|
expr: "x = 'x' OR y = 'y' OR z = 'z'",
|
|
wantRSB: "WHERE (x_cond OR y_cond OR z_cond)",
|
|
wantSB: "WHERE (x_cond OR y_cond OR z_cond)",
|
|
},
|
|
{
|
|
// RSB: attribute → TrueConditionLiteral short-circuits OR.
|
|
name: "attribute OR resource",
|
|
expr: "a = 'a' OR x = 'x'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (a_cond OR x_cond)",
|
|
},
|
|
{
|
|
name: "AND sub-expression OR resource",
|
|
expr: "x = 'x' AND y = 'y' OR z = 'z'",
|
|
wantRSB: "WHERE ((x_cond AND y_cond) OR z_cond)",
|
|
wantSB: "WHERE ((x_cond AND y_cond) OR z_cond)",
|
|
},
|
|
{
|
|
name: "parenthesized OR AND resource",
|
|
expr: "(x = 'x' OR y = 'y') AND z = 'z'",
|
|
wantRSB: "WHERE (((x_cond OR y_cond)) AND z_cond)",
|
|
wantSB: "WHERE (((x_cond OR y_cond)) AND z_cond)",
|
|
},
|
|
{
|
|
name: "NOT resource OR resource",
|
|
expr: "NOT x = 'x' OR y = 'y'",
|
|
wantRSB: "WHERE (NOT (x_cond) OR y_cond)",
|
|
wantSB: "WHERE (NOT (x_cond) OR y_cond)",
|
|
},
|
|
{
|
|
name: "NOT of parenthesized OR",
|
|
expr: "NOT (x = 'x' OR y = 'y')",
|
|
wantRSB: "WHERE NOT (((x_cond OR y_cond)))",
|
|
wantSB: "WHERE NOT (((x_cond OR y_cond)))",
|
|
},
|
|
{
|
|
// RSB: left (a→true, x_cond) → x_cond; right (b→true, y_cond) → y_cond.
|
|
name: "two mixed AND groups in OR",
|
|
expr: "a = 'a' AND x = 'x' OR b = 'b' AND y = 'y'",
|
|
wantRSB: "WHERE (x_cond OR y_cond)",
|
|
wantSB: "WHERE ((a_cond AND x_cond) OR (b_cond AND y_cond))",
|
|
},
|
|
{
|
|
// RSB: NOT(a→SkipConditionLiteral) → SkipConditionLiteral → OR short-circuits.
|
|
name: "NOT attr OR resource with OR override",
|
|
expr: "NOT a = 'a' OR x = 'x'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (NOT (a_cond) OR x_cond)",
|
|
},
|
|
{
|
|
// RSB: a → TrueConditionLiteral → OR short-circuits.
|
|
name: "all attribute keys OR",
|
|
expr: "a = 'a' OR b = 'b' OR c = 'c'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (a_cond OR b_cond OR c_cond)",
|
|
},
|
|
{
|
|
// RSB: a→SkipConditionLiteral → OR short-circuits; paren passes through; NOT(SkipConditionLiteral) → SkipConditionLiteral.
|
|
name: "NOT of three-way OR",
|
|
expr: "NOT (a = 'a' OR b = 'b' OR x = 'x')",
|
|
wantRSB: "",
|
|
wantSB: "WHERE NOT (((a_cond OR b_cond OR x_cond)))",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := PrepareWhereClause(tt.expr, rsbOpts)
|
|
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
|
}
|
|
result, err = PrepareWhereClause(tt.expr, sbOpts)
|
|
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestVisitComparison_Precedence covers AND/OR/NOT operator precedence
|
|
// (AND binds tighter than OR; NOT binds tightest).
|
|
func TestVisitComparison_Precedence(t *testing.T) {
|
|
rsbOpts, sbOpts := visitComparisonOpts(t)
|
|
tests := []visitComparisonCase{
|
|
{
|
|
// a→true short-circuits OR.
|
|
name: "attr OR attr OR resource",
|
|
expr: "a = 'a' OR b = 'b' OR x = 'x'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (a_cond OR b_cond OR x_cond)",
|
|
},
|
|
{
|
|
// AND before OR: (a AND b)→true short-circuits OR.
|
|
name: "attr AND attr OR resource",
|
|
expr: "a = 'a' AND b = 'b' OR x = 'x'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE ((a_cond AND b_cond) OR x_cond)",
|
|
},
|
|
{
|
|
// AND tighter: a as own OR branch; (b AND x) as second.
|
|
name: "attr OR attr AND resource",
|
|
expr: "a = 'a' OR b = 'b' AND x = 'x'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (a_cond OR (b_cond AND x_cond))",
|
|
},
|
|
{
|
|
// Left AND group (a,b)→true short-circuits OR.
|
|
name: "two AND groups OR",
|
|
expr: "a = 'a' AND b = 'b' OR x = 'x' AND y = 'y'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE ((a_cond AND b_cond) OR (x_cond AND y_cond))",
|
|
},
|
|
{
|
|
// RSB: NOT(a→SkipConditionLiteral)→SkipConditionLiteral stripped from AND; NOT(x_cond) remains.
|
|
name: "NOT attr AND NOT resource",
|
|
expr: "NOT a = 'a' AND NOT x = 'x'",
|
|
wantRSB: "WHERE NOT (x_cond)",
|
|
wantSB: "WHERE NOT (a_cond)",
|
|
},
|
|
{
|
|
// RSB: NOT(a→SkipConditionLiteral)→SkipConditionLiteral → OR short-circuits.
|
|
name: "NOT attr OR NOT resource",
|
|
expr: "NOT a = 'a' OR NOT x = 'x'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (NOT (a_cond) OR NOT (x_cond))",
|
|
},
|
|
{
|
|
// Parses as: (NOT a='a') OR (b='b' AND x='x')
|
|
// RSB: NOT(a→SkipConditionLiteral)→SkipConditionLiteral; (SkipConditionLiteral AND x_cond)→x_cond;
|
|
// SkipConditionLiteral OR x_cond → SkipConditionLiteral short-circuits OR.
|
|
name: "complex NOT OR AND",
|
|
expr: "NOT a = 'a' OR b = 'b' AND x = 'x'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (NOT (a_cond) OR (b_cond AND x_cond))",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := PrepareWhereClause(tt.expr, rsbOpts)
|
|
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
|
}
|
|
result, err = PrepareWhereClause(tt.expr, sbOpts)
|
|
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestVisitComparison_Parens covers parenthesized sub-expressions.
|
|
// VisitPrimary adds one extra layer of parens around real conditions;
|
|
// TrueConditionLiteral passes through unwrapped.
|
|
func TestVisitComparison_Parens(t *testing.T) {
|
|
rsbOpts, sbOpts := visitComparisonOpts(t)
|
|
tests := []visitComparisonCase{
|
|
{
|
|
// RSB: SkipConditionLiteral passes through unwrapped. SB: VisitPrimary wraps in parens.
|
|
name: "single attribute key in parens",
|
|
expr: "(a = 'a')",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (a_cond)",
|
|
},
|
|
{
|
|
// RSB: a→true stripped; VisitPrimary wraps x_cond → "(x_cond)".
|
|
name: "AND in parens with mixed keys",
|
|
expr: "(a = 'a' AND x = 'x')",
|
|
wantRSB: "WHERE (x_cond)",
|
|
wantSB: "WHERE (a_cond)",
|
|
},
|
|
{
|
|
name: "NOT of paren-AND with mixed keys",
|
|
expr: "NOT (a = 'a' AND x = 'x')",
|
|
wantRSB: "WHERE NOT ((x_cond))",
|
|
wantSB: "WHERE NOT ((a_cond))",
|
|
},
|
|
{
|
|
// RSB: left (a OR b)→true stripped by AND; right (x OR y) survives.
|
|
name: "two paren-OR groups ANDed",
|
|
expr: "(a = 'a' OR b = 'b') AND (x = 'x' OR y = 'y')",
|
|
wantRSB: "WHERE ((x_cond OR y_cond))",
|
|
wantSB: "WHERE (((a_cond OR b_cond)) AND ((x_cond OR y_cond)))",
|
|
},
|
|
{
|
|
// RSB: left (a OR b)→true → OR short-circuits.
|
|
name: "two paren-OR groups ORed",
|
|
expr: "(a = 'a' OR b = 'b') OR (x = 'x' OR y = 'y')",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (((a_cond OR b_cond)) OR ((x_cond OR y_cond)))",
|
|
},
|
|
{
|
|
// RSB: a→true stripped; (b OR x) b→true→short-circuits→true stripped; y_cond survives.
|
|
name: "paren-OR in middle of three-way AND",
|
|
expr: "a = 'a' AND (b = 'b' OR x = 'x') AND y = 'y'",
|
|
wantRSB: "WHERE y_cond",
|
|
wantSB: "WHERE (a_cond AND ((b_cond OR x_cond)) AND y_cond)",
|
|
},
|
|
{
|
|
// Each VisitPrimary(paren) adds one extra "()" layer.
|
|
name: "deeply nested parentheses",
|
|
expr: "(((x = 'x')))",
|
|
wantRSB: "WHERE (((x_cond)))",
|
|
wantSB: "",
|
|
},
|
|
{
|
|
// RSB: inner NOT(a→SkipConditionLiteral)→SkipConditionLiteral; paren passes through;
|
|
// outer NOT(SkipConditionLiteral)→SkipConditionLiteral.
|
|
// SB: structural parens accumulate around each NOT.
|
|
name: "double NOT via parens",
|
|
expr: "NOT (NOT a = 'a')",
|
|
wantRSB: "",
|
|
wantSB: "WHERE NOT ((NOT (a_cond)))",
|
|
},
|
|
{
|
|
// RSB: all attrs → SkipConditionLiteral filtered from AND → AND returns SkipConditionLiteral;
|
|
// paren passes through; NOT(SkipConditionLiteral) → SkipConditionLiteral.
|
|
name: "NOT of parenthesized all-attribute AND",
|
|
expr: "NOT (a = 'a' AND b = 'b')",
|
|
wantRSB: "",
|
|
wantSB: "WHERE NOT (((a_cond AND b_cond)))",
|
|
},
|
|
{
|
|
// RSB: a→SkipConditionLiteral short-circuits OR; paren passes through; NOT(SkipConditionLiteral)→SkipConditionLiteral.
|
|
name: "NOT of parenthesized mixed OR attr short-circuits",
|
|
expr: "NOT (a = 'a' OR x = 'x')",
|
|
wantRSB: "",
|
|
wantSB: "WHERE NOT (((a_cond OR x_cond)))",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := PrepareWhereClause(tt.expr, rsbOpts)
|
|
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
|
}
|
|
result, err = PrepareWhereClause(tt.expr, sbOpts)
|
|
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestVisitComparison_FullText covers full-text (bare string literal) expressions.
|
|
// rsbOpts has SkipFullTextFilter=true → TrueConditionLiteral.
|
|
// sbOpts has SkipFullTextFilter=false, FullTextColumn=bodyCol → "body_cond".
|
|
func TestVisitComparison_FullText(t *testing.T) {
|
|
rsbOpts, sbOpts := visitComparisonOpts(t)
|
|
tests := []visitComparisonCase{
|
|
{
|
|
name: "standalone full-text term",
|
|
expr: "'hello'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE body_cond",
|
|
},
|
|
{
|
|
// RSB: FT→true, a→true; AND propagates true.
|
|
name: "full-text AND attribute",
|
|
expr: "'hello' AND a = 'a'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (body_cond AND a_cond)",
|
|
},
|
|
{
|
|
// RSB: FT→true stripped; x_cond survives.
|
|
name: "full-text AND resource",
|
|
expr: "'hello' AND x = 'x'",
|
|
wantRSB: "WHERE x_cond",
|
|
wantSB: "WHERE body_cond",
|
|
},
|
|
{
|
|
// RSB: NOT(FT→SkipConditionLiteral)→SkipConditionLiteral. SB: structural NOT applied.
|
|
name: "NOT full-text term",
|
|
expr: "NOT 'hello'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE NOT (body_cond)",
|
|
},
|
|
{
|
|
// RSB: FT→true short-circuits OR.
|
|
name: "full-text OR resource",
|
|
expr: "'hello' OR x = 'x'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (body_cond OR x_cond)",
|
|
},
|
|
{
|
|
name: "full-text OR attribute",
|
|
expr: "'hello' OR a = 'a'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (body_cond OR a_cond)",
|
|
},
|
|
{
|
|
name: "two full-text terms ANDed",
|
|
expr: "'hello' AND 'world'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (body_cond AND body_cond)",
|
|
},
|
|
{
|
|
name: "two full-text terms ORed",
|
|
expr: "'hello' OR 'world'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (body_cond OR body_cond)",
|
|
},
|
|
{
|
|
name: "full-text in parentheses",
|
|
expr: "('hello')",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (body_cond)",
|
|
},
|
|
{
|
|
name: "two full-text AND attribute",
|
|
expr: "'hello' AND 'world' AND a = 'a'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (body_cond AND body_cond AND a_cond)",
|
|
},
|
|
{
|
|
name: "full-text OR attr OR resource all types",
|
|
expr: "'hello' OR a = 'a' OR x = 'x'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (body_cond OR a_cond OR x_cond)",
|
|
},
|
|
{
|
|
name: "NOT of paren full-text AND attr",
|
|
expr: "NOT ('hello' AND a = 'a')",
|
|
wantRSB: "",
|
|
wantSB: "WHERE NOT (((body_cond AND a_cond)))",
|
|
},
|
|
{
|
|
// RSB: NOT(FT→SkipConditionLiteral)→SkipConditionLiteral stripped from AND; x_cond survives.
|
|
name: "NOT full-text AND resource",
|
|
expr: "NOT 'hello' AND x = 'x'",
|
|
wantRSB: "WHERE x_cond",
|
|
wantSB: "WHERE NOT (body_cond)",
|
|
},
|
|
{
|
|
name: "NOT full-text OR resource",
|
|
expr: "NOT 'hello' OR x = 'x'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE (NOT (body_cond) OR x_cond)",
|
|
},
|
|
{
|
|
// RSB: FT→true stripped; x_cond survives.
|
|
name: "full-text AND BETWEEN",
|
|
expr: "'hello' AND x BETWEEN 1 AND 3",
|
|
wantRSB: "WHERE x_cond",
|
|
wantSB: "WHERE body_cond",
|
|
},
|
|
{
|
|
name: "full-text AND EXISTS",
|
|
expr: "'hello' AND x EXISTS",
|
|
wantRSB: "WHERE x_cond",
|
|
wantSB: "WHERE body_cond",
|
|
},
|
|
{
|
|
// RSB: FT→true and allVariable→true; AND propagates true.
|
|
// SB: allVariable→TrueConditionLiteral stripped; body_cond survives.
|
|
name: "full-text AND allVariable",
|
|
expr: "'hello' AND x IN $service",
|
|
wantRSB: "",
|
|
wantSB: "WHERE body_cond",
|
|
},
|
|
{
|
|
// SB: body_cond added first; then allVariable→TrueConditionLiteral short-circuits OR.
|
|
name: "full-text OR allVariable",
|
|
expr: "'hello' OR x IN $service",
|
|
wantRSB: "",
|
|
wantSB: "",
|
|
},
|
|
{
|
|
// SB: body_cond
|
|
name: "full-text with sentinel value",
|
|
expr: SkipConditionLiteral,
|
|
wantRSB: "",
|
|
wantSB: "WHERE body_cond",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := PrepareWhereClause(tt.expr, rsbOpts)
|
|
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
|
}
|
|
result, err = PrepareWhereClause(tt.expr, sbOpts)
|
|
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestVisitComparison_AllVariable covers the __all__ dynamic variable.
|
|
// IN/NOT IN $service where service="__all__" resolves to TrueConditionLiteral in both
|
|
// RSB and SB, short-circuiting OR and being stripped from AND.
|
|
// Equality with __all__ does NOT short-circuit — the variable resolves to the literal
|
|
// "__all__" string and ConditionFor is called normally.
|
|
func TestVisitComparison_AllVariable(t *testing.T) {
|
|
rsbOpts, sbOpts := visitComparisonOpts(t)
|
|
tests := []visitComparisonCase{
|
|
{
|
|
name: "IN allVariable alone",
|
|
expr: "x IN $service",
|
|
wantRSB: "",
|
|
wantSB: "",
|
|
},
|
|
{
|
|
// TrueConditionLiteral stripped from AND; y_cond remains.
|
|
name: "IN allVariable AND resource",
|
|
expr: "x IN $service AND y = 'y'",
|
|
wantRSB: "WHERE y_cond",
|
|
wantSB: "",
|
|
},
|
|
{
|
|
// TrueConditionLiteral short-circuits OR.
|
|
name: "IN allVariable OR resource",
|
|
expr: "x IN $service OR y = 'y'",
|
|
wantRSB: "",
|
|
wantSB: "",
|
|
},
|
|
{
|
|
// RSB: a IN __all__→true stripped; x_cond remains.
|
|
// SB (no OR): a IN __all__→true; x filtered → ""; AND: no real conds → true.
|
|
name: "attr IN allVariable AND resource",
|
|
expr: "a IN $service AND x = 'x'",
|
|
wantRSB: "WHERE x_cond",
|
|
wantSB: "",
|
|
},
|
|
{
|
|
// NOT IN also resolves __all__ to TrueConditionLiteral.
|
|
name: "NOT IN allVariable alone",
|
|
expr: "x NOT IN $service",
|
|
wantRSB: "",
|
|
wantSB: "",
|
|
},
|
|
{
|
|
name: "NOT IN allVariable AND resource",
|
|
expr: "x NOT IN $service AND y = 'y'",
|
|
wantRSB: "WHERE y_cond",
|
|
wantSB: "",
|
|
},
|
|
{
|
|
// NOT (x IN $service): __all__ → SkipConditionLiteral; VisitPrimary passes through;
|
|
// NOT(SkipConditionLiteral) → SkipConditionLiteral.
|
|
name: "NOT of allVariable IN",
|
|
expr: "NOT (x IN $service)",
|
|
wantRSB: "",
|
|
wantSB: "",
|
|
},
|
|
{
|
|
name: "allVariable IN AND allVariable IN",
|
|
expr: "x IN $service AND y IN $service",
|
|
wantRSB: "",
|
|
wantSB: "",
|
|
},
|
|
{
|
|
// SB: allVariable→TrueConditionLiteral stripped; body_cond survives.
|
|
name: "allVariable IN AND full-text",
|
|
expr: "x IN $service AND 'hello'",
|
|
wantRSB: "",
|
|
wantSB: "WHERE body_cond",
|
|
},
|
|
{
|
|
// Equality does not trigger __all__ short-circuit; ConditionFor called normally.
|
|
name: "equality with __all__ variable no shortcircuit",
|
|
expr: "a = $service",
|
|
wantRSB: "",
|
|
wantSB: "WHERE a_cond",
|
|
},
|
|
{
|
|
// __all__→TrueConditionLiteral stripped in AND; y_cond wrapped in paren; NOT wraps.
|
|
name: "NOT of paren with __all__ AND resource",
|
|
expr: "NOT (x IN $service AND y = 'y')",
|
|
wantRSB: "WHERE NOT ((y_cond))",
|
|
wantSB: "",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := PrepareWhereClause(tt.expr, rsbOpts)
|
|
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
|
}
|
|
result, err = PrepareWhereClause(tt.expr, sbOpts)
|
|
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestVisitComparison_FunctionCalls covers function call expressions (has, hasAny, hasAll).
|
|
// rsbOpts has SkipFunctionCalls=true → TrueConditionLiteral (function never evaluated).
|
|
// sbOpts has SkipFunctionCalls=false; has/hasAny/hasAll only support FieldContextBody,
|
|
// so calls on attribute/resource keys return an error.
|
|
func TestVisitComparison_FunctionCalls(t *testing.T) {
|
|
rsbOpts, sbOpts := visitComparisonOpts(t)
|
|
tests := []visitComparisonCase{
|
|
{
|
|
name: "has on attribute key",
|
|
expr: "has(a, 'hello')",
|
|
wantRSB: "",
|
|
wantErrSB: true,
|
|
},
|
|
{
|
|
name: "has on resource key",
|
|
expr: "has(x, 'hello')",
|
|
wantRSB: "",
|
|
wantErrSB: true,
|
|
},
|
|
{
|
|
// RSB: TrueConditionLiteral stripped by AND; x_cond remains.
|
|
name: "has AND resource key",
|
|
expr: "has(a, 'hello') AND x = 'x'",
|
|
wantRSB: "WHERE x_cond",
|
|
wantErrSB: true,
|
|
},
|
|
{
|
|
// RSB: TrueConditionLiteral short-circuits OR.
|
|
name: "has OR resource key",
|
|
expr: "has(a, 'hello') OR x = 'x'",
|
|
wantRSB: "",
|
|
wantErrSB: true,
|
|
},
|
|
{
|
|
name: "NOT of has",
|
|
expr: "NOT has(a, 'hello')",
|
|
wantRSB: "",
|
|
wantErrSB: true,
|
|
},
|
|
{
|
|
// RSB: NOT(has→SkipConditionLiteral)→SkipConditionLiteral stripped from AND; y_cond remains.
|
|
name: "NOT of has AND resource key",
|
|
expr: "NOT has(a, 'hello') AND y = 'y'",
|
|
wantRSB: "WHERE y_cond",
|
|
wantErrSB: true,
|
|
},
|
|
{
|
|
// AND binds tighter: (has AND x) OR y.
|
|
// RSB: (true AND x_cond)→x_cond; OR y_cond → (x_cond OR y_cond).
|
|
name: "has AND resource OR resource",
|
|
expr: "has(a, 'hello') AND x = 'x' OR y = 'y'",
|
|
wantRSB: "WHERE (x_cond OR y_cond)",
|
|
wantErrSB: true,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := PrepareWhereClause(tt.expr, rsbOpts)
|
|
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
|
}
|
|
result, err = PrepareWhereClause(tt.expr, sbOpts)
|
|
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestVisitComparison_UnknownKeys covers unknown key handling.
|
|
// rsbOpts has IgnoreNotFoundKeys=true → VisitComparison returns SkipConditionLiteral
|
|
// (no keys resolved); SkipConditionLiteral short-circuits OR and is stripped from AND.
|
|
// sbOpts has IgnoreNotFoundKeys=false → key lookup appends an error.
|
|
func TestVisitComparison_UnknownKeys(t *testing.T) {
|
|
rsbOpts, sbOpts := visitComparisonOpts(t)
|
|
tests := []visitComparisonCase{
|
|
{
|
|
// RSB: unknown_key → SkipConditionLiteral (no keys resolved); stripped from AND; x_cond survives.
|
|
name: "unknown key AND resource",
|
|
expr: "unknown_key = 'val' AND x = 'x'",
|
|
wantRSB: "WHERE x_cond",
|
|
wantErrSB: true,
|
|
},
|
|
{
|
|
// RSB: unknown_key not found (IgnoreNotFoundKeys=true) → SkipConditionLiteral;
|
|
// SkipConditionLiteral short-circuits OR → x_cond never evaluated → WHERE true.
|
|
name: "unknown key OR resource",
|
|
expr: "unknown_key = 'val' OR x = 'x'",
|
|
wantRSB: "",
|
|
wantErrSB: true,
|
|
},
|
|
{
|
|
// RSB: unknown_key → SkipConditionLiteral short-circuits OR → WHERE true (a=a never evaluated).
|
|
name: "unknown key OR attribute",
|
|
expr: "unknown_key = 'val' OR a = 'a'",
|
|
wantRSB: "",
|
|
wantErrSB: true,
|
|
},
|
|
{
|
|
// RSB: both → SkipConditionLiteral; all stripped from AND → AND returns SkipConditionLiteral → WHERE true.
|
|
name: "all unknown keys in AND",
|
|
expr: "unk1 = 'v' AND unk2 = 'v'",
|
|
wantRSB: "",
|
|
wantErrSB: true,
|
|
},
|
|
{
|
|
// RSB: unknown_key → SkipConditionLiteral; NOT(SkipConditionLiteral) → SkipConditionLiteral (guard);
|
|
// PrepareWhereClause converts to WHERE true.
|
|
name: "NOT of unknown key",
|
|
expr: "NOT unknown_key = 'val'",
|
|
wantRSB: "",
|
|
wantErrSB: true,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := PrepareWhereClause(tt.expr, rsbOpts)
|
|
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
|
}
|
|
result, err = PrepareWhereClause(tt.expr, sbOpts)
|
|
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestVisitComparison_SkippableLiteralValues guards against two distinct collision risks
|
|
// involving SkippableConditionLiterals ("true", "__skip__", "__skip_because_of_error__"):.
|
|
func TestVisitComparison_SkippableLiteralValues(t *testing.T) {
|
|
rsbOpts, sbOpts := visitComparisonOpts(t)
|
|
|
|
tests := []visitComparisonCase{
|
|
{
|
|
// rsbOpts: a is attr → SkipConditionLiteral → WHERE true.
|
|
// sbOpts: conditionBuilder ignores value → WHERE a_cond.
|
|
name: "value equals TrueConditionLiteral",
|
|
expr: fmt.Sprintf("a = '%s'", TrueConditionLiteral),
|
|
wantRSB: "",
|
|
wantSB: "WHERE a_cond",
|
|
},
|
|
{
|
|
name: "value equals SkipConditionLiteral",
|
|
expr: fmt.Sprintf("a = '%s'", SkipConditionLiteral),
|
|
wantRSB: "",
|
|
wantSB: "WHERE a_cond",
|
|
},
|
|
{
|
|
name: "value equals ErrorConditionLiteral",
|
|
expr: fmt.Sprintf("a = '%s'", ErrorConditionLiteral),
|
|
wantRSB: "",
|
|
wantSB: "WHERE a_cond",
|
|
},
|
|
{
|
|
// IN list whose members are all sentinel literals.
|
|
name: "IN list containing all sentinel literals",
|
|
expr: fmt.Sprintf("a IN ('%s', '%s', '%s')", TrueConditionLiteral, SkipConditionLiteral, ErrorConditionLiteral),
|
|
wantRSB: "",
|
|
wantSB: "WHERE a_cond",
|
|
},
|
|
{
|
|
// Both a and b are attribute keys → rsbOpts → WHERE true.
|
|
// sbOpts → two real conditions joined by AND.
|
|
name: "AND with sentinel value on one branch",
|
|
expr: fmt.Sprintf("a = '%s' AND b = 'real_value'", SkipConditionLiteral),
|
|
wantRSB: "",
|
|
wantSB: "WHERE (a_cond AND b_cond)",
|
|
},
|
|
{
|
|
// rsbOpts: NOT(SkipConditionLiteral) → SkipConditionLiteral → WHERE true.
|
|
// sbOpts: NOT wraps the real condition.
|
|
name: "NOT with sentinel value",
|
|
expr: fmt.Sprintf("NOT a = '%s'", TrueConditionLiteral),
|
|
wantRSB: "",
|
|
wantSB: "WHERE NOT (a_cond)",
|
|
},
|
|
{
|
|
// SkipConditionLiteral as a bare full-text search term.
|
|
// rsbOpts: SkipFullTextFilter=true → SkipConditionLiteral → WHERE true.
|
|
// sbOpts: full-text search on body column → WHERE body_cond.
|
|
name: "full text search with SkipConditionLiteral",
|
|
expr: SkipConditionLiteral,
|
|
wantRSB: "",
|
|
wantSB: "WHERE body_cond",
|
|
},
|
|
{
|
|
// TrueConditionLiteral as a bare full-text search term.
|
|
// rsbOpts: SkipFullTextFilter=true → SkipConditionLiteral → WHERE true.
|
|
// sbOpts: full-text search on body column → WHERE body_cond.
|
|
name: "full text search with TrueConditionLiteral",
|
|
expr: TrueConditionLiteral,
|
|
wantRSB: "",
|
|
wantSB: "WHERE body_cond",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := PrepareWhereClause(tt.expr, rsbOpts)
|
|
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
|
}
|
|
result, err = PrepareWhereClause(tt.expr, sbOpts)
|
|
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
|
if err == nil {
|
|
var expr string
|
|
if !result.IsEmpty() {
|
|
expr, _ = result.WhereClause.Build()
|
|
}
|
|
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
|
}
|
|
})
|
|
}
|
|
}
|