From 95eb78f01ccb5322e236b8d95c20ac14137b2525 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 30 Sep 2025 10:01:31 +0530 Subject: [PATCH 01/56] wip: engine eval context --- flagengine/engine.go | 484 ++++++++++++++++++++++ flagengine/engine_eval/context.go | 282 +++++++++++++ flagengine/engine_eval/result.go | 31 ++ flagengine/flagengine_integration_test.go | 37 +- go.mod | 1 + go.sum | 2 + 6 files changed, 816 insertions(+), 21 deletions(-) create mode 100644 flagengine/engine_eval/context.go create mode 100644 flagengine/engine_eval/result.go diff --git a/flagengine/engine.go b/flagengine/engine.go index 3f28f7fc..757d8250 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -1,11 +1,20 @@ package flagengine import ( + "fmt" + "slices" + "strconv" + "strings" + + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/blang/semver/v4" + "github.com/ohler55/ojg/jp" ) // GetEnvironmentFeatureStates returns a list of feature states for a given environment. @@ -113,3 +122,478 @@ func getIdentityFeatureStatesMap( return featureStates } + +// featureContextWithSegmentName holds a feature context along with the segment name it came from. +type featureContextWithSegmentName struct { + featureContext *engine_eval.FeatureContext + segmentName string +} + +// GetEvaluationResult computes flags and matched segments given a context and a segment matcher. +// The matcher should return true when the provided segment applies to the provided context. +func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.EvaluationResult { + const defaultPriority = 0.0 + + segments := []engine_eval.SegmentResult{} + flags := []engine_eval.FlagResult{} + segmentFeatureContexts := make(map[string]featureContextWithSegmentName) + + // Process segments + for _, segmentContext := range ec.Segments { + if !isContextInSegment(ec, &segmentContext) { + continue + } + + // Add segment to results + segments = append(segments, engine_eval.SegmentResult{ + Key: segmentContext.Key, + Name: segmentContext.Name, + }) + + // Process segment overrides + if segmentContext.Overrides != nil { + for i := range segmentContext.Overrides { + override := &segmentContext.Overrides[i] + featureKey := override.FeatureKey + + // Get priority, defaulting to 0 if not set + overridePriority := defaultPriority + if override.Priority != nil { + overridePriority = *override.Priority + } + + // Check if we should update the segment feature context + shouldUpdate := false + if existing, exists := segmentFeatureContexts[featureKey]; !exists { + shouldUpdate = true + } else { + existingPriority := defaultPriority + if existing.featureContext.Priority != nil { + existingPriority = *existing.featureContext.Priority + } + if overridePriority < existingPriority { + shouldUpdate = true + } + } + + if shouldUpdate { + segmentFeatureContexts[featureKey] = featureContextWithSegmentName{ + featureContext: override, + segmentName: segmentContext.Name, + } + } + } + } + } + + // Get identity key if identity exists + var identityKey *string + if ec.Identity != nil { + identityKey = &ec.Identity.Key + } + + // Process features + if ec.Features != nil { + for _, featureContext := range ec.Features { + // Check if we have a segment override for this feature + if segmentFeatureCtx, exists := segmentFeatureContexts[featureContext.FeatureKey]; exists { + // Use segment override + fc := segmentFeatureCtx.featureContext + reason := fmt.Sprintf("TARGETING_MATCH; segment=%s", segmentFeatureCtx.segmentName) + flags = append(flags, engine_eval.FlagResult{ + Enabled: fc.Enabled, + FeatureKey: fc.FeatureKey, + Name: fc.Name, + Reason: &reason, + Value: fc.Value, + }) + } else { + // Use default feature context + flagResult := getFlagResultFromFeatureContext(&featureContext, identityKey) + flags = append(flags, flagResult) + } + } + } + + return engine_eval.EvaluationResult{ + Context: *ec, + Flags: flags, + Segments: segments, + } +} + +// getFlagResultFromFeatureContext creates a FlagResult from a FeatureContext. +func getFlagResultFromFeatureContext(featureContext *engine_eval.FeatureContext, identityKey *string) engine_eval.FlagResult { + reason := "DEFAULT" + value := featureContext.Value + + // Handle multivariate features + if len(featureContext.Variants) > 0 && identityKey != nil && featureContext.Key != "" { + // Calculate hash percentage for the identity and feature combination + objectIds := []string{featureContext.Key, *identityKey} + hashPercentage := utils.GetHashedPercentageForObjectIds(objectIds, 1) + + // Select variant based on weighted distribution + cumulativeWeight := 0.0 + for _, variant := range featureContext.Variants { + cumulativeWeight += variant.Weight + if hashPercentage <= cumulativeWeight { + value = variant.Value + break + } + } + } + + flagResult := engine_eval.FlagResult{ + Enabled: featureContext.Enabled, + FeatureKey: featureContext.FeatureKey, + Name: featureContext.Name, + Value: value, + Reason: &reason, + } + + return flagResult +} +func isContextInSegment(ec *engine_eval.EngineEvaluationContext, segmentContext *engine_eval.SegmentContext) bool { + if len(segmentContext.Rules) == 0 { + return false + } + for i := range segmentContext.Rules { + if !contextMatchesSegmentRule(ec, &segmentContext.Rules[i], segmentContext.Key) { + return false + } + } + return true +} +func contextMatchesCondition(ec *engine_eval.EngineEvaluationContext, segmentCondition *engine_eval.Condition, segmentKey string) bool { + var contextValue engine_eval.ContextValue + if segmentCondition.Property != "" { + contextValue = getContextValue(ec, segmentCondition.Property) + } + if segmentCondition.Operator == engine_eval.PercentageSplit { + var objectIds []string + if contextValue != nil { + // Try to get string representation of the context value + var strValue string + switch v := contextValue.(type) { + case string: + strValue = v + case *engine_eval.Value: + if v != nil && v.String != nil { + strValue = *v.String + } else { + return false + } + default: + return false + } + objectIds = []string{segmentKey, strValue} + } else if ec.Identity != nil { + objectIds = []string{segmentKey, ec.Identity.Key} + } else { + return false + } + if segmentCondition.Value != nil && segmentCondition.Value.String != nil { + floatValue, _ := strconv.ParseFloat(*segmentCondition.Value.String, 64) + return utils.GetHashedPercentageForObjectIds(objectIds, 1) <= floatValue + } + return false + } + if segmentCondition.Operator == engine_eval.IsNotSet { + return contextValue == nil + } + if segmentCondition.Operator == engine_eval.IsSet { + return contextValue != nil + } + if contextValue != nil { + return match(segmentCondition.Operator, ToString(contextValue), *segmentCondition.Value.String) + } + return false +} + +func ToString(contextValue engine_eval.ContextValue) string { + if s, ok := contextValue.(string); ok { + return s + } + // Handle *engine_eval.Value type + if v, ok := contextValue.(*engine_eval.Value); ok && v != nil { + if v.String != nil { + return *v.String + } + if v.Bool != nil { + return strconv.FormatBool(*v.Bool) + } + if v.Double != nil { + return strconv.FormatFloat(*v.Double, 'f', -1, 64) + } + } + return fmt.Sprint(contextValue) +} + +func match(c engine_eval.Operator, traitValue, conditionValue string) bool { + b1, e1 := strconv.ParseBool(traitValue) + b2, e2 := strconv.ParseBool(conditionValue) + if e1 == nil && e2 == nil { + return matchBool(c, b1, b2) + } + + i1, e1 := strconv.ParseInt(traitValue, 10, 64) + i2, e2 := strconv.ParseInt(conditionValue, 10, 64) + if e1 == nil && e2 == nil { + return matchInt(c, i1, i2) + } + + f1, e1 := strconv.ParseFloat(traitValue, 64) + f2, e2 := strconv.ParseFloat(conditionValue, 64) + if e1 == nil && e2 == nil { + return matchFloat(c, f1, f2) + } + if strings.HasSuffix(conditionValue, ":semver") { + conditionVersion, err := semver.Make(conditionValue[:len(conditionValue)-7]) + if err != nil { + return false + } + return matchSemver(c, traitValue, conditionVersion) + } + + return matchString(c, traitValue, conditionValue) +} +func matchSemver(c engine_eval.Operator, traitValue string, conditionVersion semver.Version) bool { + traitVersion, err := semver.Make(traitValue) + if err != nil { + return false + } + switch c { + case engine_eval.Equal: + return traitVersion.EQ(conditionVersion) + case engine_eval.GreaterThan: + return traitVersion.GT(conditionVersion) + case engine_eval.LessThan: + return traitVersion.LT(conditionVersion) + case engine_eval.LessThanInclusive: + return traitVersion.LTE(conditionVersion) + case engine_eval.GreaterThanInclusive: + return traitVersion.GE(conditionVersion) + case engine_eval.NotEqual: + return traitVersion.NE(conditionVersion) + } + return false +} + +func matchBool(c engine_eval.Operator, v1, v2 bool) bool { + var i1, i2 int64 + if v1 { + i1 = 1 + } + if v2 { + i2 = 1 + } + return matchInt(c, i1, i2) +} +func matchInt(c engine_eval.Operator, v1, v2 int64) bool { + switch c { + case engine_eval.Equal: + return v1 == v2 + case engine_eval.GreaterThan: + return v1 > v2 + case engine_eval.LessThan: + return v1 < v2 + case engine_eval.LessThanInclusive: + return v1 <= v2 + case engine_eval.GreaterThanInclusive: + return v1 >= v2 + case engine_eval.NotEqual: + return v1 != v2 + } + return v1 == v2 +} + +func matchFloat(c engine_eval.Operator, v1, v2 float64) bool { + switch c { + case engine_eval.Equal: + return v1 == v2 + case engine_eval.GreaterThan: + return v1 > v2 + case engine_eval.LessThan: + return v1 < v2 + case engine_eval.LessThanInclusive: + return v1 <= v2 + case engine_eval.GreaterThanInclusive: + return v1 >= v2 + case engine_eval.NotEqual: + return v1 != v2 + } + return v1 == v2 +} + +func matchString(c engine_eval.Operator, v1, v2 string) bool { + switch c { + case engine_eval.Contains: + return strings.Contains(v1, v2) + case engine_eval.NotContains: + return !strings.Contains(v1, v2) + case engine_eval.In: + return slices.Contains(strings.Split(v2, ","), v1) + case engine_eval.Equal: + return v1 == v2 + case engine_eval.GreaterThan: + return v1 > v2 + case engine_eval.LessThan: + return v1 < v2 + case engine_eval.LessThanInclusive: + return v1 <= v2 + case engine_eval.GreaterThanInclusive: + return v1 >= v2 + case engine_eval.NotEqual: + return v1 != v2 + } + return v1 == v2 +} + +func getContextValue(ec *engine_eval.EngineEvaluationContext, property string) engine_eval.ContextValue { + if strings.HasPrefix(property, "$.") { + return getContextValueGetter(property)(ec) + } else if ec.Identity != nil { + if ec.Identity.Traits != nil { + value, exists := ec.Identity.Traits[property] + if exists { + return value + } + } + } + return nil +} + +func contextMatchesSegmentRule(ec *engine_eval.EngineEvaluationContext, segmentRule *engine_eval.SegmentRule, segmentKey string) bool { + matchesConditions := true + if len(segmentRule.Conditions) > 0 { + conditions := make([]bool, len(segmentRule.Conditions)) + for i := range segmentRule.Conditions { + conditions[i] = contextMatchesCondition(ec, &segmentRule.Conditions[i], segmentKey) + } + switch segmentRule.Type { + case engine_eval.All: + matchesConditions = utils.All(conditions) + case engine_eval.Any: + matchesConditions = utils.Any(conditions) + default: + matchesConditions = utils.None(conditions) + } + } + + if !matchesConditions { + return false + } + + for i := range segmentRule.Rules { + if !contextMatchesSegmentRule(ec, &segmentRule.Rules[i], segmentKey) { + return false + } + } + return true +} + +// getContextValueGetter returns a cached function to retrieve a value from a map[string]any +// using either a JSONPath expression or a fallback trait key. +func getContextValueGetter(property string) func(ec *engine_eval.EngineEvaluationContext) any { + // First, try to parse the property as a JSONPath expression. + p, err := jp.ParseString(property) + if err == nil { + // If successful, create and cache a getter for the JSONPath. + getter := func(evalCtx *engine_eval.EngineEvaluationContext) any { + // Convert the struct to a map for JSONPath evaluation + data := map[string]interface{}{ + "environment": map[string]interface{}{ + "key": evalCtx.Environment.Key, + "name": evalCtx.Environment.Name, + }, + } + + if evalCtx.Identity != nil { + identityMap := map[string]interface{}{ + "identifier": evalCtx.Identity.Identifier, + "key": evalCtx.Identity.Key, + } + if evalCtx.Identity.Traits != nil { + traits := make(map[string]interface{}) + for k, v := range evalCtx.Identity.Traits { + if v != nil { + if v.String != nil { + traits[k] = *v.String + } else if v.Bool != nil { + traits[k] = *v.Bool + } else if v.Double != nil { + traits[k] = *v.Double + } + } + } + identityMap["traits"] = traits + } + data["identity"] = identityMap + } + + results := p.Get(data) + // jp.Get returns []any - if we have one result, return it + if len(results) == 1 { + return results[0] + } else if len(results) == 0 { + return nil + } + // Return the first result if multiple + return results[0] + } + return getter + } + + // Fallback: Treat the property as a trait key under $.identity.traits. + // This handles cases where the property isn't a valid JSONPath. + fallbackPath := `$.identity.traits["` + escapeDoubleQuotes(property) + `"]` + + p, err = jp.ParseString(fallbackPath) + if err == nil { + // Create and cache the fallback getter. + getter := func(evalCtx *engine_eval.EngineEvaluationContext) any { + // Convert the struct to a map for JSONPath evaluation + data := map[string]interface{}{} + + if evalCtx.Identity != nil && evalCtx.Identity.Traits != nil { + traits := make(map[string]interface{}) + for k, v := range evalCtx.Identity.Traits { + if v != nil { + if v.String != nil { + traits[k] = *v.String + } else if v.Bool != nil { + traits[k] = *v.Bool + } else if v.Double != nil { + traits[k] = *v.Double + } + } + } + data["identity"] = map[string]interface{}{ + "traits": traits, + } + } + + results := p.Get(data) + // jp.Get returns []any - if we have one result, return it + if len(results) == 1 { + return results[0] + } else if len(results) == 0 { + return nil + } + // Return the first result if multiple + return results[0] + } + return getter + } + + // If neither parsing method works, return a function that always returns nil. + getter := func(evalCtx *engine_eval.EngineEvaluationContext) any { + return nil + } + return getter +} + +func escapeDoubleQuotes(s string) string { + return strings.ReplaceAll(s, "\"", "\\\"") +} diff --git a/flagengine/engine_eval/context.go b/flagengine/engine_eval/context.go new file mode 100644 index 00000000..76875e16 --- /dev/null +++ b/flagengine/engine_eval/context.go @@ -0,0 +1,282 @@ +package engine_eval + +import ( + "encoding/json" + "fmt" +) + +// A context object containing the necessary information to evaluate Flagsmith feature flags. +type EngineEvaluationContext struct { + // Environment context required for evaluation. + Environment EnvironmentContext `json:"environment"` + // Features to be evaluated in the context. + Features map[string]FeatureContext `json:"features,omitempty"` + // Identity context used for identity-based evaluation. + Identity *IdentityContext `json:"identity,omitempty"` + // Segments applicable to the evaluation context. + Segments map[string]SegmentContext `json:"segments,omitempty"` +} + +// Environment context required for evaluation. +// +// Represents an environment context for feature flag evaluation. +type EnvironmentContext struct { + // An environment's unique identifier. + Key string `json:"key"` + // An environment's human-readable name. + Name string `json:"name"` +} + +// Represents a feature context for feature flag evaluation. +type FeatureContext struct { + // Indicates whether the feature is enabled in the environment. + Enabled bool `json:"enabled"` + // Unique feature identifier. + FeatureKey string `json:"feature_key"` + // Key used when selecting a value for a multivariate feature. Set to an internal identifier + // or a UUID, depending on Flagsmith implementation. + Key string `json:"key"` + // Feature name. + Name string `json:"name"` + // Priority of the feature context. Lower values indicate a higher priority when multiple + // contexts apply to the same feature. + Priority *float64 `json:"priority,omitempty"` + // A default environment value for the feature. If the feature is multivariate, this will be + // the control value. + Value *Value `json:"value"` + // An array of environment default values associated with the feature. Contains a single + // value for standard features, or multiple values for multivariate features. + Variants []FeatureValue `json:"variants,omitempty"` +} + +// Represents a multivariate value for a feature flag. +type FeatureValue struct { + // The value of the feature. + Value *Value `json:"value"` + // The weight of the feature value variant, as a percentage number (i.e. 100.0). + Weight float64 `json:"weight"` +} + +// FlexibleString is a type that can unmarshal from either string or number JSON values. +type FlexibleString string + +// UnmarshalJSON implements custom JSON unmarshaling for FlexibleString. +func (f *FlexibleString) UnmarshalJSON(data []byte) error { + // Try to unmarshal as a string first + var str string + if err := json.Unmarshal(data, &str); err == nil { + *f = FlexibleString(str) + return nil + } + + // Try to unmarshal as a number + var num json.Number + if err := json.Unmarshal(data, &num); err == nil { + *f = FlexibleString(num.String()) + return nil + } + + // Try to unmarshal as any type and convert to string + var val interface{} + if err := json.Unmarshal(data, &val); err == nil { + *f = FlexibleString(fmt.Sprintf("%v", val)) + return nil + } + + return fmt.Errorf("unable to unmarshal FlexibleString from %s", string(data)) +} + +type IdentityContext struct { + // A unique identifier for an identity, used for segment and multivariate feature flag + // targeting, and displayed in the Flagsmith UI. + Identifier string `json:"identifier"` + // Key used when selecting a value for a multivariate feature, or for % split segmentation. + // Set to an internal identifier or a composite value based on the environment key and + // identifier, depending on Flagsmith implementation. + Key string `json:"key"` + // A map of traits associated with the identity, where the key is the trait name and the + // value is the trait value. + Traits map[string]*Value `json:"traits,omitempty"` +} + +// Represents a segment context for feature flag evaluation. +type SegmentContext struct { + // Key used for % split segmentation. + Key string `json:"key"` + // The name of the segment. + Name string `json:"name"` + // Feature overrides for the segment. + Overrides []FeatureContext `json:"overrides,omitempty"` + // Rules that define the segment. + Rules []SegmentRule `json:"rules"` +} + +// Represents a rule within a segment for feature flag evaluation. +type SegmentRule struct { + // Conditions that must be met for the rule to apply. + Conditions []Condition `json:"conditions,omitempty"` + // Sub-rules nested within the segment rule. + Rules []SegmentRule `json:"rules,omitempty"` + // Segment rule type. Represents a logical quantifier for the conditions and sub-rules. + Type Type `json:"type"` +} + +// Represents a condition within a segment rule for feature flag evaluation. +// +// Represents an IN condition within a segment rule for feature flag evaluation. +type Condition struct { + // The operator to use for evaluating the condition. + Operator Operator `json:"operator"` + // A reference to the identity trait or value in the evaluation context. + Property string `json:"property"` + // The value to compare against the trait or context value. + // + // The values to compare against the trait or context value. + Value *ValueUnion `json:"value"` +} + +// The operator to use for evaluating the condition. +type Operator string + +const ( + Contains Operator = "CONTAINS" + Equal Operator = "EQUAL" + GreaterThan Operator = "GREATER_THAN" + GreaterThanInclusive Operator = "GREATER_THAN_INCLUSIVE" + In Operator = "IN" + IsNotSet Operator = "IS_NOT_SET" + IsSet Operator = "IS_SET" + LessThan Operator = "LESS_THAN" + LessThanInclusive Operator = "LESS_THAN_INCLUSIVE" + Modulo Operator = "MODULO" + NotContains Operator = "NOT_CONTAINS" + NotEqual Operator = "NOT_EQUAL" + PercentageSplit Operator = "PERCENTAGE_SPLIT" + Regex Operator = "REGEX" +) + +// Segment rule type. Represents a logical quantifier for the conditions and sub-rules. +type Type string + +const ( + All Type = "ALL" + Any Type = "ANY" + None Type = "NONE" +) + +// A default environment value for the feature. If the feature is multivariate, this will be +// the control value. +// +// The value of the feature. +type Value struct { + Bool *bool + Double *float64 + String *string +} + +type ValueUnion struct { + String *string + StringArray []string +} + +// UnmarshalJSON implements custom JSON unmarshaling for Value. +func (v *Value) UnmarshalJSON(data []byte) error { + // Try to unmarshal as null first + if string(data) == "null" { + return nil + } + + // Try to unmarshal as a structured object + var structured struct { + Bool *bool `json:"bool"` + Double *float64 `json:"double"` + String *string `json:"string"` + } + if err := json.Unmarshal(data, &structured); err == nil && (structured.Bool != nil || structured.Double != nil || structured.String != nil) { + v.Bool = structured.Bool + v.Double = structured.Double + v.String = structured.String + return nil + } + + // Try to unmarshal as a raw value + var rawValue interface{} + if err := json.Unmarshal(data, &rawValue); err != nil { + return err + } + + switch val := rawValue.(type) { + case bool: + v.Bool = &val + case float64: + v.Double = &val + case string: + v.String = &val + case nil: + // Already handled above, but just in case + return nil + default: + // If it's not a basic type, convert to string + str := fmt.Sprintf("%v", val) + v.String = &str + } + + return nil +} + +// UnmarshalJSON implements custom JSON unmarshaling for ValueUnion. +func (v *ValueUnion) UnmarshalJSON(data []byte) error { + // Try to unmarshal as null first + if string(data) == "null" { + return nil + } + + // Try to unmarshal as a string array + var strArray []string + if err := json.Unmarshal(data, &strArray); err == nil { + v.StringArray = strArray + return nil + } + + // Try to unmarshal as a single string + var str string + if err := json.Unmarshal(data, &str); err == nil { + v.String = &str + return nil + } + + // Try to unmarshal as a structured object + var structured struct { + String *string `json:"string"` + StringArray []string `json:"stringArray"` + } + if err := json.Unmarshal(data, &structured); err == nil { + v.String = structured.String + v.StringArray = structured.StringArray + return nil + } + + return fmt.Errorf("unable to unmarshal ValueUnion from %s", string(data)) +} + +// UnmarshalJSON implements custom JSON unmarshaling for IdentityContext. +func (ic *IdentityContext) UnmarshalJSON(data []byte) error { + // Use an alias to avoid recursion + type Alias IdentityContext + aux := struct { + Key FlexibleString `json:"key"` + *Alias + }{ + Alias: (*Alias)(ic), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + ic.Key = string(aux.Key) + return nil +} + +// ContextValue represents allowed types: nil, int, float64, bool, string. +type ContextValue interface{} diff --git a/flagengine/engine_eval/result.go b/flagengine/engine_eval/result.go new file mode 100644 index 00000000..fc1bdf27 --- /dev/null +++ b/flagengine/engine_eval/result.go @@ -0,0 +1,31 @@ +package engine_eval + +// Evaluation result object containing the used context, flag evaluation results, and +// segments used in the evaluation. +type EvaluationResult struct { + Context EngineEvaluationContext `json:"context"` + // List of feature flags evaluated for the context. + Flags []FlagResult `json:"flags"` + // List of segments which the provided context belongs to. + Segments []SegmentResult `json:"segments"` +} + +type FlagResult struct { + // Indicates if the feature flag is enabled. + Enabled bool `json:"enabled"` + // Unique feature identifier. + FeatureKey string `json:"feature_key"` + // Feature name. + Name string `json:"name"` + // Reason for the feature flag evaluation. + Reason *string `json:"reason,omitempty"` + // Feature flag value. + Value *Value `json:"value,omitempty"` +} + +type SegmentResult struct { + // Unique segment identifier. + Key string `json:"key"` + // Segment name. + Name string `json:"name"` +} diff --git a/flagengine/flagengine_integration_test.go b/flagengine/flagengine_integration_test.go index 330702a8..657799cd 100644 --- a/flagengine/flagengine_integration_test.go +++ b/flagengine/flagengine_integration_test.go @@ -11,10 +11,8 @@ import ( "github.com/stretchr/testify/require" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine" + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" ) const TestData = "./engine-test-data/data/environment_n9fbf9h3v4fFgH3U3ngWhb.json" @@ -24,12 +22,9 @@ func TestEngine(t *testing.T) { var testData struct { Environment environments.EnvironmentModel `json:"environment"` TestCases []struct { - Identity identities.IdentityModel `json:"identity"` - Response struct { - Traits []traits.TraitModel `json:"traits"` - Flags []features.FeatureStateModel `json:"flags"` - } `json:"response"` - } `json:"identities_and_responses"` + EvaluationContext engine_eval.EngineEvaluationContext `json:"context"` + EvaluationResult engine_eval.EvaluationResult `json:"result"` + } `json:"test_cases"` } testSpec, err := os.ReadFile(TestData) @@ -40,24 +35,24 @@ func TestEngine(t *testing.T) { require.NoError(t, err) for i, c := range testData.TestCases { - t.Run(strconv.Itoa(i)+":"+c.Identity.CompositeKey(), func(t *testing.T) { + t.Run(strconv.Itoa(i), func(t *testing.T) { assert := assert.New(t) require := require.New(t) - actual := flagengine.GetIdentityFeatureStates(&testData.Environment, &c.Identity) - expected := c.Response.Flags + actual := flagengine.GetEvaluationResult(&c.EvaluationContext) + expected := c.EvaluationResult - sort.Slice(actual, func(i, j int) bool { - return actual[i].Feature.Name < actual[j].Feature.Name + sort.Slice(actual.Flags, func(i, j int) bool { + return actual.Flags[i].FeatureKey < actual.Flags[j].FeatureKey }) - sort.Slice(expected, func(i, j int) bool { - return expected[i].Feature.Name < expected[j].Feature.Name + sort.Slice(expected.Flags, func(i, j int) bool { + return expected.Flags[i].FeatureKey < expected.Flags[j].FeatureKey }) - require.Len(actual, len(expected)) - for i := range expected { - id := strconv.Itoa(c.Identity.DjangoID) - assert.Equal(expected[i].Value(id), actual[i].Value(id)) - assert.Equal(expected[i].Enabled, actual[i].Enabled) + require.Len(actual.Flags, len(expected.Flags)) + for i := range expected.Flags { + assert.Equal(expected.Flags[i].Value, actual.Flags[i].Value) + assert.Equal(expected.Flags[i].Enabled, actual.Flags[i].Enabled) + assert.Equal(expected.Flags[i].FeatureKey, actual.Flags[i].FeatureKey) } }) } diff --git a/go.mod b/go.mod index ac66e4f2..d8647e26 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/ohler55/ojg v1.26.10 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/net v0.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 4cd8d14f..59bcdefe 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/itlightning/dateparse v0.2.1 h1:AB0NJTyI0HYcerEUMovKZOiQVBg1mBPxgAnWQwzLP6g= github.com/itlightning/dateparse v0.2.1/go.mod h1:xHlmL8lT0L9JIBlaKotRwsoDYpKJskXpiU9ZwbbSkNA= +github.com/ohler55/ojg v1.26.10 h1:qXq8A0AjzwvO+rKJWv9apNVWxyu3He8lgGZZ+AoEdLA= +github.com/ohler55/ojg v1.26.10/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= From 50d47a8e723818d7e713d807e62ac0c22d82aff7 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 30 Sep 2025 11:48:19 +0530 Subject: [PATCH 02/56] update engine test data --- flagengine/engine-test-data | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flagengine/engine-test-data b/flagengine/engine-test-data index f9877115..18c68ef9 160000 --- a/flagengine/engine-test-data +++ b/flagengine/engine-test-data @@ -1 +1 @@ -Subproject commit f987711516f088897f08b4fb8ffc06383e1ad547 +Subproject commit 18c68ef925910622a228af2892aed48b21e532fe From 16b872e04f429d8c8a058a0ec96723b7899d70ad Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 30 Sep 2025 12:50:58 +0530 Subject: [PATCH 03/56] fetch flags using eval ctx for env flags --- client.go | 10 +- flagengine/engine_eval/mappers.go | 151 ++++++++++ flagengine/engine_eval/mappers_test.go | 228 +++++++++++++++ go.mod | 2 +- models.go | 43 ++- models_test.go | 368 +++++++++++++++++++++++++ 6 files changed, 794 insertions(+), 8 deletions(-) create mode 100644 flagengine/engine_eval/mappers.go create mode 100644 flagengine/engine_eval/mappers_test.go create mode 100644 models_test.go diff --git a/client.go b/client.go index afe071f8..60356898 100644 --- a/client.go +++ b/client.go @@ -12,6 +12,7 @@ import ( "time" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine" + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" @@ -352,12 +353,9 @@ func (c *Client) getEnvironmentFlagsFromEnvironment() (Flags, error) { if !ok { return Flags{}, fmt.Errorf("flagsmith: local environment has not yet been updated") } - return makeFlagsFromFeatureStates( - env.FeatureStates, - c.analyticsProcessor, - c.defaultFlagHandler, - "", - ), nil + engineEvalCtx := engine_eval.MapEnvironmentDocumentToEvaluationContext(env) + result := flagengine.GetEvaluationResult(&engineEvalCtx) + return makeFlagsFromEngineEvaluationResult(&result, c.analyticsProcessor, c.defaultFlagHandler), nil } func (c *Client) pollEnvironment(ctx context.Context, pollForever bool) { diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go new file mode 100644 index 00000000..8a632b45 --- /dev/null +++ b/flagengine/engine_eval/mappers.go @@ -0,0 +1,151 @@ +package engine_eval + +import ( + "fmt" + "strconv" + + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" +) + +// MapEnvironmentDocumentToEvaluationContext maps an environment document model +// to the higher-level EngineEvaluationContext representation used for evaluation. +func MapEnvironmentDocumentToEvaluationContext(env *environments.EnvironmentModel) EngineEvaluationContext { + ctx := EngineEvaluationContext{} + + // Environment + // map environment -> EnvironmentContext + ctx.Environment = EnvironmentContext{ + Key: env.APIKey, + Name: env.APIKey, // Default to APIKey, will be overridden below if project exists + } + if env.Project != nil { + ctx.Environment.Name = env.Project.Name + } + + // Features (environment defaults) + if len(env.FeatureStates) > 0 { + ctx.Features = make(map[string]FeatureContext, len(env.FeatureStates)) + for _, fs := range env.FeatureStates { + fc := mapFeatureStateToFeatureContext(fs) + ctx.Features[fc.Name] = fc + } + } + + // Segments + if env.Project != nil && len(env.Project.Segments) > 0 { + ctx.Segments = make(map[string]SegmentContext, len(env.Project.Segments)) + for _, s := range env.Project.Segments { + sc := mapSegmentToSegmentContext(s) + ctx.Segments[sc.Key] = sc + } + } + + return ctx +} + +func mapFeatureStateToFeatureContext(fs *features.FeatureStateModel) FeatureContext { + var key string + if fs.DjangoID != 0 { + key = strconv.Itoa(fs.DjangoID) + } else { + key = fs.FeatureStateUUID + } + + fc := FeatureContext{ + Enabled: fs.Enabled, + FeatureKey: strconv.Itoa(fs.Feature.ID), + Key: key, + Name: fs.Feature.Name, + } + + // Value + if fs.RawValue != nil { + valueStr := fmt.Sprint(fs.RawValue) + fc.Value = &Value{String: &valueStr} + } + + // Variants + if len(fs.MultivariateFeatureStateValues) > 0 { + variants := make([]FeatureValue, 0, len(fs.MultivariateFeatureStateValues)) + for _, mv := range fs.MultivariateFeatureStateValues { + valueStr := fmt.Sprint(mv.MultivariateFeatureOption.Value) + variants = append(variants, FeatureValue{ + Value: &Value{String: &valueStr}, + Weight: mv.PercentageAllocation, + }) + } + fc.Variants = variants + } + + // Priority (if present via segment override) + if fs.FeatureSegment != nil { + p := float64(fs.FeatureSegment.Priority) + fc.Priority = &p + } + + return fc +} + +func mapSegmentToSegmentContext(s *segments.SegmentModel) SegmentContext { + sc := SegmentContext{ + Key: strconv.Itoa(s.ID), + Name: s.Name, + Rules: make([]SegmentRule, 0, len(s.Rules)), + } + + // Overrides + if len(s.FeatureStates) > 0 { + for _, fs := range s.FeatureStates { + sc.Overrides = append(sc.Overrides, mapFeatureStateToFeatureContext(fs)) + } + } + + // Rules + for _, r := range s.Rules { + sc.Rules = append(sc.Rules, mapSegmentRuleToRule(r)) + } + + return sc +} + +func mapSegmentRuleToRule(r *segments.SegmentRuleModel) SegmentRule { + er := SegmentRule{Type: mapRuleType(r.Type)} + // Conditions + if len(r.Conditions) > 0 { + for _, c := range r.Conditions { + er.Conditions = append(er.Conditions, Condition{ + Operator: mapConditionOperator(c.Operator), + Property: c.Property, + Value: &ValueUnion{String: &c.Value}, + }) + } + } + // Nested rules + if len(r.Rules) > 0 { + for _, sr := range r.Rules { + er.Rules = append(er.Rules, mapSegmentRuleToRule(sr)) + } + } + return er +} + +func mapRuleType(t segments.RuleType) Type { + switch t { + case segments.All: + return All + case segments.Any: + return Any + default: + return None + } +} + +func mapConditionOperator(op segments.ConditionOperator) Operator { + // Normalise NOT EQUAL -> NOT_EQUAL + if op == "NOT EQUAL" { + return NotEqual + } + return Operator(op) +} diff --git a/flagengine/engine_eval/mappers_test.go b/flagengine/engine_eval/mappers_test.go new file mode 100644 index 00000000..20d27dd8 --- /dev/null +++ b/flagengine/engine_eval/mappers_test.go @@ -0,0 +1,228 @@ +package engine_eval + +import ( + "testing" + "time" + + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/projects" + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" +) + +func TestMapEnvironmentDocumentToEvaluationContext(t *testing.T) { + // Create test data + env := &environments.EnvironmentModel{ + ID: 1, + APIKey: "test-api-key", + Project: &projects.ProjectModel{ + ID: 1, + Name: "Test Project", + Segments: []*segments.SegmentModel{ + { + ID: 1, + Name: "test-segment", + Rules: []*segments.SegmentRuleModel{ + { + Type: segments.All, + Conditions: []*segments.SegmentConditionModel{ + { + Operator: "EQUAL", + Property: "test_property", + Value: "test_value", + }, + }, + }, + }, + FeatureStates: []*features.FeatureStateModel{ + { + Enabled: true, + Feature: &features.FeatureModel{ + ID: 1, + Name: "segment-override-feature", + }, + RawValue: "segment-value", + }, + }, + }, + }, + }, + FeatureStates: []*features.FeatureStateModel{ + { + Enabled: true, + Feature: &features.FeatureModel{ + ID: 1, + Name: "test-feature", + }, + RawValue: "test-value", + FeatureStateUUID: "test-uuid", + DjangoID: 123, + }, + { + Enabled: false, + Feature: &features.FeatureModel{ + ID: 2, + Name: "disabled-feature", + }, + RawValue: nil, + }, + }, + UpdatedAt: time.Now(), + } + + // Test the mapping function + result := MapEnvironmentDocumentToEvaluationContext(env) + + // Test Environment mapping + if result.Environment.Key != "test-api-key" { + t.Errorf("Expected Environment.Key to be 'test-api-key', got %v", result.Environment.Key) + } + if result.Environment.Name != "Test Project" { + t.Errorf("Expected Environment.Name to be 'Test Project', got %v", result.Environment.Name) + } + + // Test Features mapping + if len(result.Features) != 2 { + t.Errorf("Expected 2 features, got %d", len(result.Features)) + } + + // Test first feature + testFeature, exists := result.Features["test-feature"] + if !exists { + t.Error("Expected 'test-feature' to exist in Features map") + } else { + if !testFeature.Enabled { + t.Error("Expected test-feature to be enabled") + } + if testFeature.FeatureKey != "1" { + t.Errorf("Expected FeatureKey to be '1' (feature ID), got %v", testFeature.FeatureKey) + } + if testFeature.Name != "test-feature" { + t.Errorf("Expected Name to be 'test-feature', got %v", testFeature.Name) + } + if testFeature.Key != "123" { + t.Errorf("Expected Key to be '123' (from DjangoID), got %v", testFeature.Key) + } + if testFeature.Value == nil || testFeature.Value.String == nil || *testFeature.Value.String != "test-value" { + t.Errorf("Expected Value.String to be 'test-value', got %v", testFeature.Value) + } + } + + // Test second feature (disabled with nil value) + disabledFeature, exists := result.Features["disabled-feature"] + if !exists { + t.Error("Expected 'disabled-feature' to exist in Features map") + } else { + if disabledFeature.Enabled { + t.Error("Expected disabled-feature to be disabled") + } + if disabledFeature.Value != nil { + t.Errorf("Expected Value to be nil for disabled feature, got %v", disabledFeature.Value) + } + } + + // Test Segments mapping + if len(result.Segments) != 1 { + t.Errorf("Expected 1 segment, got %d", len(result.Segments)) + } + + testSegment, exists := result.Segments["1"] + if !exists { + t.Error("Expected segment with key '1' to exist in Segments map") + } else { + if testSegment.Name != "test-segment" { + t.Errorf("Expected segment name to be 'test-segment', got %v", testSegment.Name) + } + if testSegment.Key != "1" { + t.Errorf("Expected segment key to be '1', got %v", testSegment.Key) + } + + // Test segment rules + if len(testSegment.Rules) != 1 { + t.Errorf("Expected 1 rule in segment, got %d", len(testSegment.Rules)) + } else { + rule := testSegment.Rules[0] + if rule.Type != All { + t.Errorf("Expected rule type to be All, got %v", rule.Type) + } + if len(rule.Conditions) != 1 { + t.Errorf("Expected 1 condition in rule, got %d", len(rule.Conditions)) + } else { + condition := rule.Conditions[0] + if condition.Operator != "EQUAL" { + t.Errorf("Expected condition operator to be 'EQUAL', got %v", condition.Operator) + } + if condition.Property != "test_property" { + t.Errorf("Expected condition property to be 'test_property', got %v", condition.Property) + } + if condition.Value == nil || condition.Value.String == nil || *condition.Value.String != "test_value" { + t.Errorf("Expected condition value to be 'test_value', got %v", condition.Value) + } + } + } + + // Test segment overrides + if len(testSegment.Overrides) != 1 { + t.Errorf("Expected 1 override in segment, got %d", len(testSegment.Overrides)) + } else { + override := testSegment.Overrides[0] + if override.FeatureKey != "1" { + t.Errorf("Expected override feature key to be '1' (feature ID), got %v", override.FeatureKey) + } + if !override.Enabled { + t.Error("Expected segment override to be enabled") + } + if override.Value == nil || override.Value.String == nil || *override.Value.String != "segment-value" { + t.Errorf("Expected override value to be 'segment-value', got %v", override.Value) + } + } + } +} + +func TestMapEnvironmentDocumentToEvaluationContextWithNilProject(t *testing.T) { + env := &environments.EnvironmentModel{ + ID: 1, + APIKey: "test-api-key", + Project: nil, + FeatureStates: []*features.FeatureStateModel{}, + UpdatedAt: time.Now(), + } + + result := MapEnvironmentDocumentToEvaluationContext(env) + + // When project is nil, name should default to APIKey + if result.Environment.Name != "test-api-key" { + t.Errorf("Expected Environment.Name to default to APIKey 'test-api-key', got %v", result.Environment.Name) + } + + // Should have no segments when project is nil + if len(result.Segments) != 0 { + t.Errorf("Expected 0 segments when project is nil, got %d", len(result.Segments)) + } +} + +func TestMapEnvironmentDocumentToEvaluationContextWithEmptyFeatureStates(t *testing.T) { + env := &environments.EnvironmentModel{ + ID: 1, + APIKey: "test-api-key", + Project: &projects.ProjectModel{ + ID: 1, + Name: "Test Project", + Segments: []*segments.SegmentModel{}, + }, + FeatureStates: []*features.FeatureStateModel{}, + UpdatedAt: time.Now(), + } + + result := MapEnvironmentDocumentToEvaluationContext(env) + + // Should have no features when FeatureStates is empty + if len(result.Features) != 0 { + t.Errorf("Expected 0 features when FeatureStates is empty, got %d", len(result.Features)) + } + + // Should have no segments when project segments is empty + if len(result.Segments) != 0 { + t.Errorf("Expected 0 segments when project segments is empty, got %d", len(result.Segments)) + } +} diff --git a/go.mod b/go.mod index d8647e26..4044cea6 100644 --- a/go.mod +++ b/go.mod @@ -12,11 +12,11 @@ require ( require ( github.com/go-resty/resty/v2 v2.16.5 github.com/itlightning/dateparse v0.2.1 + github.com/ohler55/ojg v1.26.10 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/ohler55/ojg v1.26.10 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/net v0.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/models.go b/models.go index 1d4530c1..b869612c 100644 --- a/models.go +++ b/models.go @@ -3,9 +3,10 @@ package flagsmith import ( "encoding/json" "fmt" + "strconv" + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" ) @@ -46,6 +47,33 @@ func makeFlagFromFeatureState(featureState *features.FeatureStateModel, identity } } +func makeFlagFromEngineEvaluationFlagResult(flagResult *engine_eval.FlagResult) Flag { + var value interface{} + if flagResult.Value != nil { + if flagResult.Value.String != nil { + value = *flagResult.Value.String + } else if flagResult.Value.Bool != nil { + value = *flagResult.Value.Bool + } else if flagResult.Value.Double != nil { + value = *flagResult.Value.Double + } + } + + // Convert FeatureKey (string ID) to integer FeatureID + featureID := 0 + if id, err := strconv.Atoi(flagResult.FeatureKey); err == nil { + featureID = id + } + + return Flag{ + Enabled: flagResult.Enabled, + Value: value, + IsDefault: false, + FeatureID: featureID, + FeatureName: flagResult.Name, + } +} + type Flags struct { flags []Flag analyticsProcessor *AnalyticsProcessor @@ -68,6 +96,19 @@ func makeFlagsFromFeatureStates(featureStates []*features.FeatureStateModel, } } +func makeFlagsFromEngineEvaluationResult(evaluationResult *engine_eval.EvaluationResult, analyticsProcessor *AnalyticsProcessor, defaultFlagHandler func(string) (Flag, error)) Flags { + flags := make([]Flag, len(evaluationResult.Flags)) + for i, flagResult := range evaluationResult.Flags { + flags[i] = makeFlagFromEngineEvaluationFlagResult(&flagResult) + } + + return Flags{ + flags: flags, + analyticsProcessor: analyticsProcessor, + defaultFlagHandler: defaultFlagHandler, + } +} + type jsonFeature struct { ID int `json:"id"` Name string `json:"name"` diff --git a/models_test.go b/models_test.go new file mode 100644 index 00000000..f60dd7b3 --- /dev/null +++ b/models_test.go @@ -0,0 +1,368 @@ +package flagsmith + +import ( + "testing" + + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" +) + +func TestMakeFlagFromEngineEvaluationFlagResult(t *testing.T) { + tests := []struct { + name string + input *engine_eval.FlagResult + expected Flag + }{ + { + name: "flag with string value", + input: &engine_eval.FlagResult{ + Enabled: true, + FeatureKey: "test_feature_key", + Name: "test_feature", + Value: &engine_eval.Value{ + String: stringPtr("test_value"), + }, + }, + expected: Flag{ + Enabled: true, + Value: "test_value", + IsDefault: false, + FeatureID: 0, + FeatureName: "test_feature", + }, + }, + { + name: "flag with boolean value", + input: &engine_eval.FlagResult{ + Enabled: false, + FeatureKey: "bool_feature_key", + Name: "bool_feature", + Value: &engine_eval.Value{ + Bool: boolPtr(true), + }, + }, + expected: Flag{ + Enabled: false, + Value: true, + IsDefault: false, + FeatureID: 0, + FeatureName: "bool_feature", + }, + }, + { + name: "flag with double value", + input: &engine_eval.FlagResult{ + Enabled: true, + FeatureKey: "double_feature_key", + Name: "double_feature", + Value: &engine_eval.Value{ + Double: float64Ptr(42.5), + }, + }, + expected: Flag{ + Enabled: true, + Value: 42.5, + IsDefault: false, + FeatureID: 0, + FeatureName: "double_feature", + }, + }, + { + name: "flag with nil value", + input: &engine_eval.FlagResult{ + Enabled: true, + FeatureKey: "nil_feature_key", + Name: "nil_feature", + Value: nil, + }, + expected: Flag{ + Enabled: true, + Value: nil, + IsDefault: false, + FeatureID: 0, + FeatureName: "nil_feature", + }, + }, + { + name: "flag with empty value struct", + input: &engine_eval.FlagResult{ + Enabled: false, + FeatureKey: "empty_feature_key", + Name: "empty_feature", + Value: &engine_eval.Value{}, + }, + expected: Flag{ + Enabled: false, + Value: nil, + IsDefault: false, + FeatureID: 0, + FeatureName: "empty_feature", + }, + }, + { + name: "flag with zero values", + input: &engine_eval.FlagResult{ + Enabled: false, + FeatureKey: "", + Name: "", + Value: &engine_eval.Value{ + String: stringPtr(""), + }, + }, + expected: Flag{ + Enabled: false, + Value: "", + IsDefault: false, + FeatureID: 0, + FeatureName: "", + }, + }, + { + name: "flag with reason field (should be ignored in conversion)", + input: &engine_eval.FlagResult{ + Enabled: true, + FeatureKey: "reason_feature_key", + Name: "reason_feature", + Reason: stringPtr("TARGETING_MATCH"), + Value: &engine_eval.Value{ + String: stringPtr("reason_value"), + }, + }, + expected: Flag{ + Enabled: true, + Value: "reason_value", + IsDefault: false, + FeatureID: 0, + FeatureName: "reason_feature", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := makeFlagFromEngineEvaluationFlagResult(tt.input) + + if result.Enabled != tt.expected.Enabled { + t.Errorf("Expected Enabled %v, got %v", tt.expected.Enabled, result.Enabled) + } + if result.Value != tt.expected.Value { + t.Errorf("Expected Value %v, got %v", tt.expected.Value, result.Value) + } + if result.IsDefault != tt.expected.IsDefault { + t.Errorf("Expected IsDefault %v, got %v", tt.expected.IsDefault, result.IsDefault) + } + if result.FeatureID != tt.expected.FeatureID { + t.Errorf("Expected FeatureID %v, got %v", tt.expected.FeatureID, result.FeatureID) + } + if result.FeatureName != tt.expected.FeatureName { + t.Errorf("Expected FeatureName %v, got %v", tt.expected.FeatureName, result.FeatureName) + } + }) + } +} + +func TestMakeFlagsFromEngineEvaluationResult(t *testing.T) { + tests := []struct { + name string + input *engine_eval.EvaluationResult + expected []Flag + }{ + { + name: "evaluation result with multiple flags", + input: &engine_eval.EvaluationResult{ + Context: engine_eval.EngineEvaluationContext{}, + Flags: []engine_eval.FlagResult{ + { + Enabled: true, + FeatureKey: "feature1_key", + Name: "feature1", + Value: &engine_eval.Value{ + String: stringPtr("value1"), + }, + }, + { + Enabled: false, + FeatureKey: "feature2_key", + Name: "feature2", + Value: &engine_eval.Value{ + Bool: boolPtr(true), + }, + }, + { + Enabled: true, + FeatureKey: "feature3_key", + Name: "feature3", + Value: &engine_eval.Value{ + Double: float64Ptr(123.45), + }, + }, + }, + Segments: []engine_eval.SegmentResult{}, + }, + expected: []Flag{ + { + Enabled: true, + Value: "value1", + IsDefault: false, + FeatureID: 0, + FeatureName: "feature1", + }, + { + Enabled: false, + Value: true, + IsDefault: false, + FeatureID: 0, + FeatureName: "feature2", + }, + { + Enabled: true, + Value: 123.45, + IsDefault: false, + FeatureID: 0, + FeatureName: "feature3", + }, + }, + }, + { + name: "evaluation result with no flags", + input: &engine_eval.EvaluationResult{ + Context: engine_eval.EngineEvaluationContext{}, + Flags: []engine_eval.FlagResult{}, + Segments: []engine_eval.SegmentResult{}, + }, + expected: []Flag{}, + }, + { + name: "evaluation result with single flag", + input: &engine_eval.EvaluationResult{ + Context: engine_eval.EngineEvaluationContext{}, + Flags: []engine_eval.FlagResult{ + { + Enabled: true, + FeatureKey: "single_feature_key", + Name: "single_feature", + Value: &engine_eval.Value{ + String: stringPtr("single_value"), + }, + }, + }, + Segments: []engine_eval.SegmentResult{}, + }, + expected: []Flag{ + { + Enabled: true, + Value: "single_value", + IsDefault: false, + FeatureID: 0, + FeatureName: "single_feature", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := makeFlagsFromEngineEvaluationResult(tt.input, nil, nil) + + if len(result.flags) != len(tt.expected) { + t.Errorf("Expected %d flags, got %d", len(tt.expected), len(result.flags)) + return + } + + for i, expectedFlag := range tt.expected { + actualFlag := result.flags[i] + + if actualFlag.Enabled != expectedFlag.Enabled { + t.Errorf("Flag %d: Expected Enabled %v, got %v", i, expectedFlag.Enabled, actualFlag.Enabled) + } + if actualFlag.Value != expectedFlag.Value { + t.Errorf("Flag %d: Expected Value %v, got %v", i, expectedFlag.Value, actualFlag.Value) + } + if actualFlag.IsDefault != expectedFlag.IsDefault { + t.Errorf("Flag %d: Expected IsDefault %v, got %v", i, expectedFlag.IsDefault, actualFlag.IsDefault) + } + if actualFlag.FeatureID != expectedFlag.FeatureID { + t.Errorf("Flag %d: Expected FeatureID %v, got %v", i, expectedFlag.FeatureID, actualFlag.FeatureID) + } + if actualFlag.FeatureName != expectedFlag.FeatureName { + t.Errorf("Flag %d: Expected FeatureName %v, got %v", i, expectedFlag.FeatureName, actualFlag.FeatureName) + } + } + + // Test that analytics processor and default flag handler are set correctly + if result.analyticsProcessor != nil { + t.Errorf("Expected analyticsProcessor to be nil, got non-nil value") + } + if result.defaultFlagHandler != nil { + t.Errorf("Expected defaultFlagHandler to be nil, got non-nil function") + } + }) + } +} + +func TestMakeFlagsFromEngineEvaluationResultWithProcessorAndHandler(t *testing.T) { + // Mock analytics processor + mockAnalyticsProcessor := &AnalyticsProcessor{} + + // Mock default flag handler + mockDefaultFlagHandler := func(featureName string) (Flag, error) { + return Flag{ + Enabled: false, + Value: "default", + IsDefault: true, + FeatureID: -1, + FeatureName: featureName, + }, nil + } + + input := &engine_eval.EvaluationResult{ + Context: engine_eval.EngineEvaluationContext{}, + Flags: []engine_eval.FlagResult{ + { + Enabled: true, + FeatureKey: "test_feature_key", + Name: "test_feature", + Value: &engine_eval.Value{ + String: stringPtr("test_value"), + }, + }, + }, + Segments: []engine_eval.SegmentResult{}, + } + + result := makeFlagsFromEngineEvaluationResult(input, mockAnalyticsProcessor, mockDefaultFlagHandler) + + // Test that analytics processor and default flag handler are set correctly + if result.analyticsProcessor != mockAnalyticsProcessor { + t.Errorf("Expected analyticsProcessor to be set correctly") + } + if result.defaultFlagHandler == nil { + t.Errorf("Expected defaultFlagHandler to be set") + } + + // Test that the handler works + if result.defaultFlagHandler != nil { + flag, err := result.defaultFlagHandler("test") + if err != nil { + t.Errorf("Unexpected error from defaultFlagHandler: %v", err) + } + if flag.FeatureName != "test" { + t.Errorf("Expected handler to return flag with name 'test', got %v", flag.FeatureName) + } + if !flag.IsDefault { + t.Errorf("Expected handler to return default flag") + } + } +} + +// Helper functions for creating pointers. +func stringPtr(s string) *string { + return &s +} + +func boolPtr(b bool) *bool { + return &b +} + +func float64Ptr(f float64) *float64 { + return &f +} From 0c3e335d31ecd9745de4c6e349d594cc550cdfa8 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 30 Sep 2025 13:51:09 +0530 Subject: [PATCH 04/56] use context for identity flags --- client.go | 13 +- flagengine/engine_eval/mappers.go | 255 ++++++++++++++++++++- flagengine/engine_eval/mappers_test.go | 301 +++++++++++++++++++++++++ 3 files changed, 559 insertions(+), 10 deletions(-) diff --git a/client.go b/client.go index 60356898..4e04e57b 100644 --- a/client.go +++ b/client.go @@ -337,15 +337,10 @@ func (c *Client) getIdentityFlagsFromEnvironment(identifier string, traits []*Tr if !ok { return Flags{}, fmt.Errorf("flagsmith: local environment has not yet been updated") } - identity := c.getIdentityModel(identifier, env.APIKey, traits) - featureStates := flagengine.GetIdentityFeatureStates(env, &identity) - flags := makeFlagsFromFeatureStates( - featureStates, - c.analyticsProcessor, - c.defaultFlagHandler, - identifier, - ) - return flags, nil + engineEvalCtx := engine_eval.MapEnvironmentDocumentToEvaluationContext(env) + engineEvalCtx = engine_eval.MapContextAndIdentityDataToContext(engineEvalCtx, identifier, traits) + result := flagengine.GetEvaluationResult(&engineEvalCtx) + return makeFlagsFromEngineEvaluationResult(&result, c.analyticsProcessor, c.defaultFlagHandler), nil } func (c *Client) getEnvironmentFlagsFromEnvironment() (Flags, error) { diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index 8a632b45..10c44bf2 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -1,11 +1,18 @@ package engine_eval import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" "fmt" + "math" + "sort" "strconv" + "strings" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" ) @@ -33,7 +40,7 @@ func MapEnvironmentDocumentToEvaluationContext(env *environments.EnvironmentMode } } - // Segments + // Segments (from project) if env.Project != nil && len(env.Project.Segments) > 0 { ctx.Segments = make(map[string]SegmentContext, len(env.Project.Segments)) for _, s := range env.Project.Segments { @@ -42,6 +49,17 @@ func MapEnvironmentDocumentToEvaluationContext(env *environments.EnvironmentMode } } + // Identity overrides (mapped to segments) + if len(env.IdentityOverrides) > 0 { + identitySegments := mapIdentityOverridesToSegments(env.IdentityOverrides) + if ctx.Segments == nil { + ctx.Segments = make(map[string]SegmentContext) + } + for key, segment := range identitySegments { + ctx.Segments[key] = segment + } + } + return ctx } @@ -149,3 +167,238 @@ func mapConditionOperator(op segments.ConditionOperator) Operator { } return Operator(op) } + +// overridesKey represents a unique set of feature overrides for grouping identities. +type overridesKey struct { + featureKey string + featureName string + enabled bool + featureValue string +} + +// overridesKeyList is a sortable slice of overridesKey. +type overridesKeyList []overridesKey + +func (o overridesKeyList) Len() int { return len(o) } +func (o overridesKeyList) Swap(i, j int) { o[i], o[j] = o[j], o[i] } +func (o overridesKeyList) Less(i, j int) bool { return o[i].featureName < o[j].featureName } + +// generateHash creates a hash from the overrides key for use as segment key. +func generateHash(overrides overridesKeyList) string { + // Sort to ensure consistent hash for same set of overrides + sort.Sort(overrides) + + // Create a string representation of the overrides + var hashInput string + for _, override := range overrides { + hashInput += fmt.Sprintf("%s:%s:%t:%s;", override.featureKey, override.featureName, override.enabled, override.featureValue) + } + + // Generate SHA256 hash + hash := sha256.Sum256([]byte(hashInput)) + return hex.EncodeToString(hash[:])[:16] // Use first 16 characters for shorter key +} + +// This groups identities by their common feature overrides and creates segments for each group. +func mapIdentityOverridesToSegments(identityOverrides []*identities.IdentityModel) map[string]SegmentContext { + // Map from overrides key to list of identifiers + featuresToIdentifiers := make(map[string][]string) + overridesKeyToList := make(map[string]overridesKeyList) + + for _, identityOverride := range identityOverrides { + if len(identityOverride.IdentityFeatures) == 0 { + continue + } + + // Create overrides key from sorted features + var overrides overridesKeyList + for _, featureState := range identityOverride.IdentityFeatures { + featureValue := "" + if featureState.RawValue != nil { + featureValue = fmt.Sprint(featureState.RawValue) + } + + overrides = append(overrides, overridesKey{ + featureKey: strconv.Itoa(featureState.Feature.ID), + featureName: featureState.Feature.Name, + enabled: featureState.Enabled, + featureValue: featureValue, + }) + } + + // Generate hash for this set of overrides + overridesHash := generateHash(overrides) + + // Group identifiers by their overrides + featuresToIdentifiers[overridesHash] = append(featuresToIdentifiers[overridesHash], identityOverride.Identifier) + overridesKeyToList[overridesHash] = overrides + } + + // Create segment contexts for each unique set of overrides + segmentContexts := make(map[string]SegmentContext) + + for overridesHash, identifiers := range featuresToIdentifiers { + overrides := overridesKeyToList[overridesHash] + + // Create segment context + sc := SegmentContext{ + Key: "", // Identity override segments never use % Split operator + Name: "identity_overrides", + Rules: []SegmentRule{ + { + Type: All, + Conditions: []Condition{ + { + Operator: "IN", + Property: "$.identity.identifier", + Value: &ValueUnion{String: func() *string { s := strings.Join(identifiers, ","); return &s }()}, + }, + }, + }, + }, + } + + // Create overrides for each feature + for _, override := range overrides { + priority := math.Inf(-1) // Highest possible priority + featureOverride := FeatureContext{ + Key: "", // Identity overrides never carry multivariate options + FeatureKey: override.featureKey, + Name: override.featureName, + Enabled: override.enabled, + Priority: &priority, + } + + // Set the value if provided + if override.featureValue != "" { + featureOverride.Value = &Value{String: &override.featureValue} + } + + sc.Overrides = append(sc.Overrides, featureOverride) + } + + segmentContexts[overridesHash] = sc + } + + return segmentContexts +} + +// Trait represents a trait with key-value pair, compatible with the main package Trait struct. +type Trait struct { + TraitKey string `json:"trait_key"` + TraitValue interface{} `json:"trait_value"` + Transient bool `json:"transient,omitempty"` +} + +// MapContextAndIdentityDataToContext maps context and identity data to create an evaluation context +// with identity information. This function takes an existing context and enriches it with identity +// data including identifier and traits. +func MapContextAndIdentityDataToContext( + context EngineEvaluationContext, + identifier string, + traits interface{}, +) EngineEvaluationContext { + // Convert traits to local type + var traitList []*Trait + + if traits != nil { + // Handle different trait types by copying field values + switch v := traits.(type) { + case []*Trait: + traitList = v + default: + // Try to extract traits using reflection-like approach + // Since both Trait structs have the same JSON tags, we can marshal/unmarshal + if jsonBytes, err := json.Marshal(traits); err == nil { + if err := json.Unmarshal(jsonBytes, &traitList); err != nil { + // Log error or handle gracefully - for now, continue with empty list + traitList = nil + } + } + } + } + // Create a copy of the context + newContext := context + + // Create traits map for the identity + identityTraits := make(map[string]*Value) + + for _, trait := range traitList { + if trait == nil { + continue + } + + // Convert trait value to *Value + valuePtr := convertTraitValueToValue(trait.TraitValue) + if valuePtr != nil { + identityTraits[trait.TraitKey] = valuePtr + } + } + + // Create the identity context + var environmentKey string + if newContext.Environment.Key != "" { + environmentKey = newContext.Environment.Key + } else { + environmentKey = newContext.Environment.Name + } + + identity := IdentityContext{ + Identifier: identifier, + Key: fmt.Sprintf("%s_%s", environmentKey, identifier), + Traits: identityTraits, + } + + // Set the identity in the context + newContext.Identity = &identity + + return newContext +} + +// This function handles interface{} values and converts them appropriately. +func convertTraitValueToValue(traitValue interface{}) *Value { + if traitValue == nil { + return nil + } + + switch v := traitValue.(type) { + case bool: + return &Value{Bool: &v} + case int: + f := float64(v) + return &Value{Double: &f} + case int64: + f := float64(v) + return &Value{Double: &f} + case float64: + return &Value{Double: &v} + case float32: + f := float64(v) + return &Value{Double: &f} + case string: + if v == "" { + return nil + } + // Try to parse string as boolean + if v == "true" { + b := true + return &Value{Bool: &b} + } else if v == "false" { + b := false + return &Value{Bool: &b} + } + // Try to parse string as float64 + if f, err := strconv.ParseFloat(v, 64); err == nil { + return &Value{Double: &f} + } + // Default to string + return &Value{String: &v} + default: + // For other types, convert to string + str := fmt.Sprint(v) + if str == "" { + return nil + } + return &Value{String: &str} + } +} diff --git a/flagengine/engine_eval/mappers_test.go b/flagengine/engine_eval/mappers_test.go index 20d27dd8..6d895731 100644 --- a/flagengine/engine_eval/mappers_test.go +++ b/flagengine/engine_eval/mappers_test.go @@ -1,13 +1,16 @@ package engine_eval import ( + "math" "testing" "time" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/projects" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" ) func TestMapEnvironmentDocumentToEvaluationContext(t *testing.T) { @@ -226,3 +229,301 @@ func TestMapEnvironmentDocumentToEvaluationContextWithEmptyFeatureStates(t *test t.Errorf("Expected 0 segments when project segments is empty, got %d", len(result.Segments)) } } + +func TestMapEnvironmentDocumentToEvaluationContextWithIdentityOverrides(t *testing.T) { + env := &environments.EnvironmentModel{ + ID: 1, + APIKey: "test-api-key", + Project: &projects.ProjectModel{ + ID: 1, + Name: "Test Project", + Segments: []*segments.SegmentModel{}, + }, + FeatureStates: []*features.FeatureStateModel{}, + IdentityOverrides: []*identities.IdentityModel{ + { + Identifier: "user1", + EnvironmentAPIKey: "test-api-key", + CreatedDate: utils.ISOTime{Time: time.Now()}, + IdentityUUID: "uuid-1", + IdentityFeatures: []*features.FeatureStateModel{ + { + Enabled: true, + Feature: &features.FeatureModel{ + ID: 1, + Name: "feature_1", + }, + RawValue: "override_value_1", + }, + { + Enabled: false, + Feature: &features.FeatureModel{ + ID: 2, + Name: "feature_2", + }, + RawValue: "override_value_2", + }, + }, + }, + { + Identifier: "user2", + EnvironmentAPIKey: "test-api-key", + CreatedDate: utils.ISOTime{Time: time.Now()}, + IdentityUUID: "uuid-2", + IdentityFeatures: []*features.FeatureStateModel{ + { + Enabled: true, + Feature: &features.FeatureModel{ + ID: 1, + Name: "feature_1", + }, + RawValue: "override_value_1", + }, + { + Enabled: false, + Feature: &features.FeatureModel{ + ID: 2, + Name: "feature_2", + }, + RawValue: "override_value_2", + }, + }, + }, + { + Identifier: "user3", + EnvironmentAPIKey: "test-api-key", + CreatedDate: utils.ISOTime{Time: time.Now()}, + IdentityUUID: "uuid-3", + IdentityFeatures: []*features.FeatureStateModel{ + { + Enabled: false, + Feature: &features.FeatureModel{ + ID: 1, + Name: "feature_1", + }, + RawValue: "different_value", + }, + }, + }, + }, + UpdatedAt: time.Now(), + } + + result := MapEnvironmentDocumentToEvaluationContext(env) + + // Should have created segments from identity overrides + if len(result.Segments) != 2 { + t.Errorf("Expected 2 segments (one for user1+user2 with same overrides, one for user3), got %d", len(result.Segments)) + } + + // Check that segments have the correct structure + foundIdentitySegments := 0 + for _, segment := range result.Segments { + if segment.Name == "identity_overrides" { + foundIdentitySegments++ + + // Should have one rule of type All + if len(segment.Rules) != 1 { + t.Errorf("Expected 1 rule in identity override segment, got %d", len(segment.Rules)) + } else { + rule := segment.Rules[0] + if rule.Type != All { + t.Errorf("Expected rule type to be All, got %v", rule.Type) + } + + // Should have one condition for identity identifier + if len(rule.Conditions) != 1 { + t.Errorf("Expected 1 condition in rule, got %d", len(rule.Conditions)) + } else { + condition := rule.Conditions[0] + if condition.Operator != "IN" { + t.Errorf("Expected condition operator to be 'IN', got %v", condition.Operator) + } + if condition.Property != "$.identity.identifier" { + t.Errorf("Expected condition property to be '$.identity.identifier', got %v", condition.Property) + } + if condition.Value == nil || condition.Value.String == nil { + t.Error("Expected condition value to have String") + } + } + } + + // Should have feature overrides + if len(segment.Overrides) == 0 { + t.Error("Expected identity override segment to have feature overrides") + } + + // Check override priorities are set to negative infinity + for _, override := range segment.Overrides { + if override.Priority == nil { + t.Error("Expected feature override to have priority set") + } else if *override.Priority != math.Inf(-1) { + t.Errorf("Expected priority to be negative infinity, got %v", *override.Priority) + } + } + } + } + + if foundIdentitySegments != 2 { + t.Errorf("Expected to find 2 identity override segments, found %d", foundIdentitySegments) + } +} + +func TestMapContextAndIdentityDataToContext(t *testing.T) { + // Create a base context + baseContext := EngineEvaluationContext{ + Environment: EnvironmentContext{ + Key: "test-env-key", + Name: "Test Environment", + }, + Features: map[string]FeatureContext{ + "test-feature": { + Enabled: true, + FeatureKey: "1", + Name: "test-feature", + }, + }, + } + + // Test with different trait value types + traitList := []*Trait{ + {TraitKey: "string_trait", TraitValue: "string_value"}, + {TraitKey: "int_trait", TraitValue: 42}, + {TraitKey: "float_trait", TraitValue: 3.14}, + {TraitKey: "bool_true_trait", TraitValue: true}, + {TraitKey: "bool_false_trait", TraitValue: false}, + {TraitKey: "string_number_trait", TraitValue: "99"}, + {TraitKey: "string_bool_trait", TraitValue: "true"}, + {TraitKey: "empty_trait", TraitValue: ""}, + } + + result := MapContextAndIdentityDataToContext(baseContext, "test-user", traitList) + + // Check that the original context is preserved + if result.Environment.Key != "test-env-key" { + t.Errorf("Expected environment key to be preserved, got %v", result.Environment.Key) + } + if result.Environment.Name != "Test Environment" { + t.Errorf("Expected environment name to be preserved, got %v", result.Environment.Name) + } + if len(result.Features) != 1 { + t.Errorf("Expected features to be preserved, got %d features", len(result.Features)) + } + + // Check identity context + if result.Identity == nil { + t.Fatal("Expected identity to be set") + } + + identity := result.Identity + if identity.Identifier != "test-user" { + t.Errorf("Expected identifier to be 'test-user', got %v", identity.Identifier) + } + if identity.Key != "test-env-key_test-user" { + t.Errorf("Expected key to be 'test-env-key_test-user', got %v", identity.Key) + } + + // Check traits + if identity.Traits == nil { + t.Fatal("Expected traits to be set") + } + + // Test string trait + if stringTrait, exists := identity.Traits["string_trait"]; !exists { + t.Error("Expected string_trait to exist") + } else if stringTrait.String == nil || *stringTrait.String != "string_value" { + t.Errorf("Expected string_trait to be 'string_value', got %v", stringTrait) + } + + // Test int trait (int 42 converted to float64) + if intTrait, exists := identity.Traits["int_trait"]; !exists { + t.Error("Expected int_trait to exist") + } else if intTrait.Double == nil || *intTrait.Double != 42.0 { + t.Errorf("Expected int_trait to be 42.0, got %v", intTrait) + } + + // Test float trait (float64 3.14) + if floatTrait, exists := identity.Traits["float_trait"]; !exists { + t.Error("Expected float_trait to exist") + } else if floatTrait.Double == nil || *floatTrait.Double != 3.14 { + t.Errorf("Expected float_trait to be 3.14, got %v", floatTrait) + } + + // Test bool true trait (bool true) + if boolTrueTrait, exists := identity.Traits["bool_true_trait"]; !exists { + t.Error("Expected bool_true_trait to exist") + } else if boolTrueTrait.Bool == nil || *boolTrueTrait.Bool != true { + t.Errorf("Expected bool_true_trait to be true, got %v", boolTrueTrait) + } + + // Test bool false trait (bool false) + if boolFalseTrait, exists := identity.Traits["bool_false_trait"]; !exists { + t.Error("Expected bool_false_trait to exist") + } else if boolFalseTrait.Bool == nil || *boolFalseTrait.Bool != false { + t.Errorf("Expected bool_false_trait to be false, got %v", boolFalseTrait) + } + + // Test string number trait (string "99" parsed as float64) + if stringNumberTrait, exists := identity.Traits["string_number_trait"]; !exists { + t.Error("Expected string_number_trait to exist") + } else if stringNumberTrait.Double == nil || *stringNumberTrait.Double != 99.0 { + t.Errorf("Expected string_number_trait to be 99.0, got %v", stringNumberTrait) + } + + // Test string bool trait (string "true" parsed as bool) + if stringBoolTrait, exists := identity.Traits["string_bool_trait"]; !exists { + t.Error("Expected string_bool_trait to exist") + } else if stringBoolTrait.Bool == nil || *stringBoolTrait.Bool != true { + t.Errorf("Expected string_bool_trait to be true, got %v", stringBoolTrait) + } + + // Test empty trait (should not be included) + if _, exists := identity.Traits["empty_trait"]; exists { + t.Error("Expected empty_trait to not be included") + } +} + +func TestMapContextAndIdentityDataToContextWithNilTraits(t *testing.T) { + baseContext := EngineEvaluationContext{ + Environment: EnvironmentContext{ + Key: "test-env-key", + Name: "Test Environment", + }, + } + + result := MapContextAndIdentityDataToContext(baseContext, "test-user", nil) + + // Check identity context + if result.Identity == nil { + t.Fatal("Expected identity to be set") + } + + identity := result.Identity + if identity.Identifier != "test-user" { + t.Errorf("Expected identifier to be 'test-user', got %v", identity.Identifier) + } + if identity.Key != "test-env-key_test-user" { + t.Errorf("Expected key to be 'test-env-key_test-user', got %v", identity.Key) + } + + // Should have empty traits map when nil traits passed + if len(identity.Traits) != 0 { + t.Errorf("Expected empty traits map, got %d traits", len(identity.Traits)) + } +} + +func TestMapContextAndIdentityDataToContextWithEmptyEnvironmentKey(t *testing.T) { + baseContext := EngineEvaluationContext{ + Environment: EnvironmentContext{ + Key: "", // Empty key + Name: "Test Environment", + }, + } + + result := MapContextAndIdentityDataToContext(baseContext, "test-user", nil) + + // Should use environment name when key is empty + if result.Identity.Key != "Test Environment_test-user" { + t.Errorf("Expected key to use environment name when key is empty, got %v", result.Identity.Key) + } +} From 984cb774019e5174e43b850ff0d411f8512d1940 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 30 Sep 2025 15:02:29 +0530 Subject: [PATCH 05/56] store and use engine eval context instead of env doc --- client.go | 22 +++++++++++++++------- models.go | 28 ---------------------------- 2 files changed, 15 insertions(+), 35 deletions(-) diff --git a/client.go b/client.go index 4e04e57b..d216a511 100644 --- a/client.go +++ b/client.go @@ -31,6 +31,7 @@ type Client struct { config config environment atomic.Value + evaluationContext atomic.Value identitiesWithOverrides atomic.Value analyticsProcessor *AnalyticsProcessor @@ -141,7 +142,11 @@ func NewClient(apiKey string, options ...Option) *Client { panic("local evaluation and offline handler cannot be used together.") } if c.offlineHandler != nil { - c.environment.Store(c.offlineHandler.GetEnvironment()) + env := c.offlineHandler.GetEnvironment() + c.environment.Store(env) + // Update evaluation context atomically for offline environment + engineEvalCtx := engine_eval.MapEnvironmentDocumentToEvaluationContext(env) + c.evaluationContext.Store(&engineEvalCtx) } if c.config.localEvaluation { @@ -333,23 +338,21 @@ func (c *Client) GetIdentityFlagsFromAPI(ctx context.Context, identifier string, } func (c *Client) getIdentityFlagsFromEnvironment(identifier string, traits []*Trait) (Flags, error) { - env, ok := c.environment.Load().(*environments.EnvironmentModel) + evalCtx, ok := c.evaluationContext.Load().(*engine_eval.EngineEvaluationContext) if !ok { return Flags{}, fmt.Errorf("flagsmith: local environment has not yet been updated") } - engineEvalCtx := engine_eval.MapEnvironmentDocumentToEvaluationContext(env) - engineEvalCtx = engine_eval.MapContextAndIdentityDataToContext(engineEvalCtx, identifier, traits) + engineEvalCtx := engine_eval.MapContextAndIdentityDataToContext(*evalCtx, identifier, traits) result := flagengine.GetEvaluationResult(&engineEvalCtx) return makeFlagsFromEngineEvaluationResult(&result, c.analyticsProcessor, c.defaultFlagHandler), nil } func (c *Client) getEnvironmentFlagsFromEnvironment() (Flags, error) { - env, ok := c.environment.Load().(*environments.EnvironmentModel) + evalCtx, ok := c.evaluationContext.Load().(*engine_eval.EngineEvaluationContext) if !ok { return Flags{}, fmt.Errorf("flagsmith: local environment has not yet been updated") } - engineEvalCtx := engine_eval.MapEnvironmentDocumentToEvaluationContext(env) - result := flagengine.GetEvaluationResult(&engineEvalCtx) + result := flagengine.GetEvaluationResult(evalCtx) return makeFlagsFromEngineEvaluationResult(&result, c.analyticsProcessor, c.defaultFlagHandler), nil } @@ -453,6 +456,11 @@ func (c *Client) UpdateEnvironment(ctx context.Context) error { isNew = true } c.environment.Store(&env) + + // Update evaluation context atomically when environment changes + engineEvalCtx := engine_eval.MapEnvironmentDocumentToEvaluationContext(&env) + c.evaluationContext.Store(&engineEvalCtx) + identitiesWithOverrides := make(map[string]identities.IdentityModel) for _, id := range env.IdentityOverrides { identitiesWithOverrides[id.Identifier] = *id diff --git a/models.go b/models.go index b869612c..5b091116 100644 --- a/models.go +++ b/models.go @@ -6,7 +6,6 @@ import ( "strconv" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" ) @@ -36,17 +35,6 @@ func (t *Trait) ToTraitModel() *traits.TraitModel { TraitValue: fmt.Sprint(t.TraitValue), } } - -func makeFlagFromFeatureState(featureState *features.FeatureStateModel, identityID string) Flag { - return Flag{ - Enabled: featureState.Enabled, - Value: featureState.Value(identityID), - IsDefault: false, - FeatureID: featureState.Feature.ID, - FeatureName: featureState.Feature.Name, - } -} - func makeFlagFromEngineEvaluationFlagResult(flagResult *engine_eval.FlagResult) Flag { var value interface{} if flagResult.Value != nil { @@ -80,22 +68,6 @@ type Flags struct { defaultFlagHandler func(featureName string) (Flag, error) } -func makeFlagsFromFeatureStates(featureStates []*features.FeatureStateModel, - analyticsProcessor *AnalyticsProcessor, - defaultFlagHandler func(featureName string) (Flag, error), - identityID string) Flags { - flags := make([]Flag, len(featureStates)) - for i, featureState := range featureStates { - flags[i] = makeFlagFromFeatureState(featureState, identityID) - } - - return Flags{ - flags: flags, - analyticsProcessor: analyticsProcessor, - defaultFlagHandler: defaultFlagHandler, - } -} - func makeFlagsFromEngineEvaluationResult(evaluationResult *engine_eval.EvaluationResult, analyticsProcessor *AnalyticsProcessor, defaultFlagHandler func(string) (Flag, error)) Flags { flags := make([]Flag, len(evaluationResult.Flags)) for i, flagResult := range evaluationResult.Flags { From 91a9ad13b3b3461c14b7c802fdbca8952f70dc98 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 30 Sep 2025 16:31:50 +0530 Subject: [PATCH 06/56] use eval context for getidentity segments --- client.go | 43 +++--------- flagengine/engine_eval/mappers.go | 29 ++++++++ flagengine/engine_eval/mappers_test.go | 93 ++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 35 deletions(-) diff --git a/client.go b/client.go index d216a511..11d2f1f3 100644 --- a/client.go +++ b/client.go @@ -14,11 +14,8 @@ import ( "github.com/Flagsmith/flagsmith-go-client/v4/flagengine" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" "github.com/go-resty/resty/v2" - - enginetraits "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" ) type contextKey string @@ -30,9 +27,8 @@ type Client struct { apiKey string config config - environment atomic.Value - evaluationContext atomic.Value - identitiesWithOverrides atomic.Value + environment atomic.Value + evaluationContext atomic.Value analyticsProcessor *AnalyticsProcessor realtime *realtime @@ -235,9 +231,12 @@ func (c *Client) GetIdentityFlags(ctx context.Context, identifier string, traits // Returns an array of segments that the given identity is part of. func (c *Client) GetIdentitySegments(identifier string, traits []*Trait) ([]*segments.SegmentModel, error) { - if env, ok := c.environment.Load().(*environments.EnvironmentModel); ok { - identity := c.getIdentityModel(identifier, env.APIKey, traits) - return flagengine.GetIdentitySegments(env, &identity), nil + if evalCtx, ok := c.evaluationContext.Load().(*engine_eval.EngineEvaluationContext); ok { + engineEvalCtx := engine_eval.MapContextAndIdentityDataToContext(*evalCtx, identifier, traits) + result := flagengine.GetEvaluationResult(&engineEvalCtx) + + // Use the new mapper to convert evaluation result segments to SegmentModel + return engine_eval.MapEvaluationResultSegmentsToSegmentModels(&result), nil } return nil, &FlagsmithClientError{msg: "flagsmith: Local evaluation required to obtain identity segments"} } @@ -461,35 +460,9 @@ func (c *Client) UpdateEnvironment(ctx context.Context) error { engineEvalCtx := engine_eval.MapEnvironmentDocumentToEvaluationContext(&env) c.evaluationContext.Store(&engineEvalCtx) - identitiesWithOverrides := make(map[string]identities.IdentityModel) - for _, id := range env.IdentityOverrides { - identitiesWithOverrides[id.Identifier] = *id - } - c.identitiesWithOverrides.Store(identitiesWithOverrides) - if isNew { c.log.Info("environment updated", "environment", env.APIKey, "updated_at", env.UpdatedAt) } return nil } - -func (c *Client) getIdentityModel(identifier string, apiKey string, traits []*Trait) identities.IdentityModel { - identityTraits := make([]*enginetraits.TraitModel, len(traits)) - for i, trait := range traits { - identityTraits[i] = trait.ToTraitModel() - } - - identitiesWithOverrides, _ := c.identitiesWithOverrides.Load().(map[string]identities.IdentityModel) - identity, ok := identitiesWithOverrides[identifier] - if ok { - identity.IdentityTraits = identityTraits - return identity - } - - return identities.IdentityModel{ - Identifier: identifier, - IdentityTraits: identityTraits, - EnvironmentAPIKey: apiKey, - } -} diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index 10c44bf2..cf60e4b6 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -402,3 +402,32 @@ func convertTraitValueToValue(traitValue interface{}) *Value { return &Value{String: &str} } } + +// MapEvaluationResultSegmentsToSegmentModels converts evaluation result segments +// to segments.SegmentModel with only ID and Name populated. +func MapEvaluationResultSegmentsToSegmentModels( + result *EvaluationResult, +) []*segments.SegmentModel { + if len(result.Segments) == 0 { + return nil + } + + segmentModels := make([]*segments.SegmentModel, 0, len(result.Segments)) + + for _, segmentResult := range result.Segments { + // Convert key to ID + id := 0 + if parsedID, err := strconv.Atoi(segmentResult.Key); err == nil { + id = parsedID + } + + segmentModel := &segments.SegmentModel{ + ID: id, + Name: segmentResult.Name, + } + + segmentModels = append(segmentModels, segmentModel) + } + + return segmentModels +} diff --git a/flagengine/engine_eval/mappers_test.go b/flagengine/engine_eval/mappers_test.go index 6d895731..69c7f853 100644 --- a/flagengine/engine_eval/mappers_test.go +++ b/flagengine/engine_eval/mappers_test.go @@ -527,3 +527,96 @@ func TestMapContextAndIdentityDataToContextWithEmptyEnvironmentKey(t *testing.T) t.Errorf("Expected key to use environment name when key is empty, got %v", result.Identity.Key) } } + +func TestMapEvaluationResultSegmentsToSegmentModels(t *testing.T) { + // Create a test evaluation result with segments + result := EvaluationResult{ + Segments: []SegmentResult{ + { + Key: "1", + Name: "test-segment", + }, + { + Key: "42", + Name: "another-segment", + }, + }, + } + + // Test the mapper + segmentModels := MapEvaluationResultSegmentsToSegmentModels(&result) + + // Assertions + if len(segmentModels) != 2 { + t.Errorf("Expected 2 segment models, got %d", len(segmentModels)) + } + + // First segment + segment1 := segmentModels[0] + if segment1.ID != 1 { + t.Errorf("Expected segment ID to be 1, got %d", segment1.ID) + } + + if segment1.Name != "test-segment" { + t.Errorf("Expected segment name to be 'test-segment', got %s", segment1.Name) + } + + // Rules and FeatureStates should be nil/empty since we only populate ID and Name + if segment1.Rules != nil { + t.Errorf("Expected Rules to be nil, got %v", segment1.Rules) + } + + if segment1.FeatureStates != nil { + t.Errorf("Expected FeatureStates to be nil, got %v", segment1.FeatureStates) + } + + // Second segment + segment2 := segmentModels[1] + if segment2.ID != 42 { + t.Errorf("Expected segment ID to be 42, got %d", segment2.ID) + } + + if segment2.Name != "another-segment" { + t.Errorf("Expected segment name to be 'another-segment', got %s", segment2.Name) + } +} + +func TestMapEvaluationResultSegmentsToSegmentModelsEmpty(t *testing.T) { + // Test with empty segments + result := EvaluationResult{ + Segments: []SegmentResult{}, + } + + segmentModels := MapEvaluationResultSegmentsToSegmentModels(&result) + + if segmentModels != nil { + t.Errorf("Expected nil for empty segments, got %v", segmentModels) + } +} + +func TestMapEvaluationResultSegmentsToSegmentModelsInvalidKey(t *testing.T) { + // Test with segment result that has invalid key (non-numeric) + result := EvaluationResult{ + Segments: []SegmentResult{ + { + Key: "invalid-key", + Name: "segment-with-invalid-key", + }, + }, + } + + segmentModels := MapEvaluationResultSegmentsToSegmentModels(&result) + + if len(segmentModels) != 1 { + t.Errorf("Expected 1 segment model, got %d", len(segmentModels)) + } + + segment := segmentModels[0] + if segment.ID != 0 { + t.Errorf("Expected segment ID to be 0 for invalid key, got %d", segment.ID) + } + + if segment.Name != "segment-with-invalid-key" { + t.Errorf("Expected segment name to be 'segment-with-invalid-key', got %s", segment.Name) + } +} From 4fa190af0fa4cb87fcbb511f1fda5a07ac6d1494 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 1 Oct 2025 08:58:03 +0530 Subject: [PATCH 07/56] Refactor: Move evaluator to engine_eval --- flagengine/engine.go | 350 +--------- flagengine/engine_eval/evaluator.go | 311 +++++++++ flagengine/engine_eval/evaluator_test.go | 810 +++++++++++++++++++++++ flagengine/segments/evaluator.go | 197 ------ flagengine/segments/evaluator_test.go | 503 -------------- flagengine/segments/models.go | 46 -- 6 files changed, 1122 insertions(+), 1095 deletions(-) create mode 100644 flagengine/engine_eval/evaluator.go create mode 100644 flagengine/engine_eval/evaluator_test.go delete mode 100644 flagengine/segments/evaluator_test.go diff --git a/flagengine/engine.go b/flagengine/engine.go index 757d8250..6ba093b8 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -2,9 +2,6 @@ package flagengine import ( "fmt" - "slices" - "strconv" - "strings" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" @@ -13,8 +10,6 @@ import ( "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" - "github.com/blang/semver/v4" - "github.com/ohler55/ojg/jp" ) // GetEnvironmentFeatureStates returns a list of feature states for a given environment. @@ -140,7 +135,7 @@ func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.Ev // Process segments for _, segmentContext := range ec.Segments { - if !isContextInSegment(ec, &segmentContext) { + if !engine_eval.IsContextInSegment(ec, &segmentContext) { continue } @@ -254,346 +249,3 @@ func getFlagResultFromFeatureContext(featureContext *engine_eval.FeatureContext, return flagResult } -func isContextInSegment(ec *engine_eval.EngineEvaluationContext, segmentContext *engine_eval.SegmentContext) bool { - if len(segmentContext.Rules) == 0 { - return false - } - for i := range segmentContext.Rules { - if !contextMatchesSegmentRule(ec, &segmentContext.Rules[i], segmentContext.Key) { - return false - } - } - return true -} -func contextMatchesCondition(ec *engine_eval.EngineEvaluationContext, segmentCondition *engine_eval.Condition, segmentKey string) bool { - var contextValue engine_eval.ContextValue - if segmentCondition.Property != "" { - contextValue = getContextValue(ec, segmentCondition.Property) - } - if segmentCondition.Operator == engine_eval.PercentageSplit { - var objectIds []string - if contextValue != nil { - // Try to get string representation of the context value - var strValue string - switch v := contextValue.(type) { - case string: - strValue = v - case *engine_eval.Value: - if v != nil && v.String != nil { - strValue = *v.String - } else { - return false - } - default: - return false - } - objectIds = []string{segmentKey, strValue} - } else if ec.Identity != nil { - objectIds = []string{segmentKey, ec.Identity.Key} - } else { - return false - } - if segmentCondition.Value != nil && segmentCondition.Value.String != nil { - floatValue, _ := strconv.ParseFloat(*segmentCondition.Value.String, 64) - return utils.GetHashedPercentageForObjectIds(objectIds, 1) <= floatValue - } - return false - } - if segmentCondition.Operator == engine_eval.IsNotSet { - return contextValue == nil - } - if segmentCondition.Operator == engine_eval.IsSet { - return contextValue != nil - } - if contextValue != nil { - return match(segmentCondition.Operator, ToString(contextValue), *segmentCondition.Value.String) - } - return false -} - -func ToString(contextValue engine_eval.ContextValue) string { - if s, ok := contextValue.(string); ok { - return s - } - // Handle *engine_eval.Value type - if v, ok := contextValue.(*engine_eval.Value); ok && v != nil { - if v.String != nil { - return *v.String - } - if v.Bool != nil { - return strconv.FormatBool(*v.Bool) - } - if v.Double != nil { - return strconv.FormatFloat(*v.Double, 'f', -1, 64) - } - } - return fmt.Sprint(contextValue) -} - -func match(c engine_eval.Operator, traitValue, conditionValue string) bool { - b1, e1 := strconv.ParseBool(traitValue) - b2, e2 := strconv.ParseBool(conditionValue) - if e1 == nil && e2 == nil { - return matchBool(c, b1, b2) - } - - i1, e1 := strconv.ParseInt(traitValue, 10, 64) - i2, e2 := strconv.ParseInt(conditionValue, 10, 64) - if e1 == nil && e2 == nil { - return matchInt(c, i1, i2) - } - - f1, e1 := strconv.ParseFloat(traitValue, 64) - f2, e2 := strconv.ParseFloat(conditionValue, 64) - if e1 == nil && e2 == nil { - return matchFloat(c, f1, f2) - } - if strings.HasSuffix(conditionValue, ":semver") { - conditionVersion, err := semver.Make(conditionValue[:len(conditionValue)-7]) - if err != nil { - return false - } - return matchSemver(c, traitValue, conditionVersion) - } - - return matchString(c, traitValue, conditionValue) -} -func matchSemver(c engine_eval.Operator, traitValue string, conditionVersion semver.Version) bool { - traitVersion, err := semver.Make(traitValue) - if err != nil { - return false - } - switch c { - case engine_eval.Equal: - return traitVersion.EQ(conditionVersion) - case engine_eval.GreaterThan: - return traitVersion.GT(conditionVersion) - case engine_eval.LessThan: - return traitVersion.LT(conditionVersion) - case engine_eval.LessThanInclusive: - return traitVersion.LTE(conditionVersion) - case engine_eval.GreaterThanInclusive: - return traitVersion.GE(conditionVersion) - case engine_eval.NotEqual: - return traitVersion.NE(conditionVersion) - } - return false -} - -func matchBool(c engine_eval.Operator, v1, v2 bool) bool { - var i1, i2 int64 - if v1 { - i1 = 1 - } - if v2 { - i2 = 1 - } - return matchInt(c, i1, i2) -} -func matchInt(c engine_eval.Operator, v1, v2 int64) bool { - switch c { - case engine_eval.Equal: - return v1 == v2 - case engine_eval.GreaterThan: - return v1 > v2 - case engine_eval.LessThan: - return v1 < v2 - case engine_eval.LessThanInclusive: - return v1 <= v2 - case engine_eval.GreaterThanInclusive: - return v1 >= v2 - case engine_eval.NotEqual: - return v1 != v2 - } - return v1 == v2 -} - -func matchFloat(c engine_eval.Operator, v1, v2 float64) bool { - switch c { - case engine_eval.Equal: - return v1 == v2 - case engine_eval.GreaterThan: - return v1 > v2 - case engine_eval.LessThan: - return v1 < v2 - case engine_eval.LessThanInclusive: - return v1 <= v2 - case engine_eval.GreaterThanInclusive: - return v1 >= v2 - case engine_eval.NotEqual: - return v1 != v2 - } - return v1 == v2 -} - -func matchString(c engine_eval.Operator, v1, v2 string) bool { - switch c { - case engine_eval.Contains: - return strings.Contains(v1, v2) - case engine_eval.NotContains: - return !strings.Contains(v1, v2) - case engine_eval.In: - return slices.Contains(strings.Split(v2, ","), v1) - case engine_eval.Equal: - return v1 == v2 - case engine_eval.GreaterThan: - return v1 > v2 - case engine_eval.LessThan: - return v1 < v2 - case engine_eval.LessThanInclusive: - return v1 <= v2 - case engine_eval.GreaterThanInclusive: - return v1 >= v2 - case engine_eval.NotEqual: - return v1 != v2 - } - return v1 == v2 -} - -func getContextValue(ec *engine_eval.EngineEvaluationContext, property string) engine_eval.ContextValue { - if strings.HasPrefix(property, "$.") { - return getContextValueGetter(property)(ec) - } else if ec.Identity != nil { - if ec.Identity.Traits != nil { - value, exists := ec.Identity.Traits[property] - if exists { - return value - } - } - } - return nil -} - -func contextMatchesSegmentRule(ec *engine_eval.EngineEvaluationContext, segmentRule *engine_eval.SegmentRule, segmentKey string) bool { - matchesConditions := true - if len(segmentRule.Conditions) > 0 { - conditions := make([]bool, len(segmentRule.Conditions)) - for i := range segmentRule.Conditions { - conditions[i] = contextMatchesCondition(ec, &segmentRule.Conditions[i], segmentKey) - } - switch segmentRule.Type { - case engine_eval.All: - matchesConditions = utils.All(conditions) - case engine_eval.Any: - matchesConditions = utils.Any(conditions) - default: - matchesConditions = utils.None(conditions) - } - } - - if !matchesConditions { - return false - } - - for i := range segmentRule.Rules { - if !contextMatchesSegmentRule(ec, &segmentRule.Rules[i], segmentKey) { - return false - } - } - return true -} - -// getContextValueGetter returns a cached function to retrieve a value from a map[string]any -// using either a JSONPath expression or a fallback trait key. -func getContextValueGetter(property string) func(ec *engine_eval.EngineEvaluationContext) any { - // First, try to parse the property as a JSONPath expression. - p, err := jp.ParseString(property) - if err == nil { - // If successful, create and cache a getter for the JSONPath. - getter := func(evalCtx *engine_eval.EngineEvaluationContext) any { - // Convert the struct to a map for JSONPath evaluation - data := map[string]interface{}{ - "environment": map[string]interface{}{ - "key": evalCtx.Environment.Key, - "name": evalCtx.Environment.Name, - }, - } - - if evalCtx.Identity != nil { - identityMap := map[string]interface{}{ - "identifier": evalCtx.Identity.Identifier, - "key": evalCtx.Identity.Key, - } - if evalCtx.Identity.Traits != nil { - traits := make(map[string]interface{}) - for k, v := range evalCtx.Identity.Traits { - if v != nil { - if v.String != nil { - traits[k] = *v.String - } else if v.Bool != nil { - traits[k] = *v.Bool - } else if v.Double != nil { - traits[k] = *v.Double - } - } - } - identityMap["traits"] = traits - } - data["identity"] = identityMap - } - - results := p.Get(data) - // jp.Get returns []any - if we have one result, return it - if len(results) == 1 { - return results[0] - } else if len(results) == 0 { - return nil - } - // Return the first result if multiple - return results[0] - } - return getter - } - - // Fallback: Treat the property as a trait key under $.identity.traits. - // This handles cases where the property isn't a valid JSONPath. - fallbackPath := `$.identity.traits["` + escapeDoubleQuotes(property) + `"]` - - p, err = jp.ParseString(fallbackPath) - if err == nil { - // Create and cache the fallback getter. - getter := func(evalCtx *engine_eval.EngineEvaluationContext) any { - // Convert the struct to a map for JSONPath evaluation - data := map[string]interface{}{} - - if evalCtx.Identity != nil && evalCtx.Identity.Traits != nil { - traits := make(map[string]interface{}) - for k, v := range evalCtx.Identity.Traits { - if v != nil { - if v.String != nil { - traits[k] = *v.String - } else if v.Bool != nil { - traits[k] = *v.Bool - } else if v.Double != nil { - traits[k] = *v.Double - } - } - } - data["identity"] = map[string]interface{}{ - "traits": traits, - } - } - - results := p.Get(data) - // jp.Get returns []any - if we have one result, return it - if len(results) == 1 { - return results[0] - } else if len(results) == 0 { - return nil - } - // Return the first result if multiple - return results[0] - } - return getter - } - - // If neither parsing method works, return a function that always returns nil. - getter := func(evalCtx *engine_eval.EngineEvaluationContext) any { - return nil - } - return getter -} - -func escapeDoubleQuotes(s string) string { - return strings.ReplaceAll(s, "\"", "\\\"") -} diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go new file mode 100644 index 00000000..e11dc5a9 --- /dev/null +++ b/flagengine/engine_eval/evaluator.go @@ -0,0 +1,311 @@ +package engine_eval + +import ( + "fmt" + "slices" + "strconv" + "strings" + + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/blang/semver/v4" + "github.com/ohler55/ojg/jp" +) + +// IsContextInSegment determines if the given evaluation context matches the segment rules. +func IsContextInSegment(ec *EngineEvaluationContext, segmentContext *SegmentContext) bool { + if len(segmentContext.Rules) == 0 { + return false + } + for i := range segmentContext.Rules { + if !contextMatchesSegmentRule(ec, &segmentContext.Rules[i], segmentContext.Key) { + return false + } + } + return true +} + +func contextMatchesSegmentRule(ec *EngineEvaluationContext, segmentRule *SegmentRule, segmentKey string) bool { + matchesConditions := true + if len(segmentRule.Conditions) > 0 { + conditions := make([]bool, len(segmentRule.Conditions)) + for i := range segmentRule.Conditions { + conditions[i] = contextMatchesCondition(ec, &segmentRule.Conditions[i], segmentKey) + } + switch segmentRule.Type { + case All: + matchesConditions = utils.All(conditions) + case Any: + matchesConditions = utils.Any(conditions) + default: + matchesConditions = utils.None(conditions) + } + } + + if !matchesConditions { + return false + } + + for i := range segmentRule.Rules { + if !contextMatchesSegmentRule(ec, &segmentRule.Rules[i], segmentKey) { + return false + } + } + return true +} + +func contextMatchesCondition(ec *EngineEvaluationContext, segmentCondition *Condition, segmentKey string) bool { + var contextValue ContextValue + if segmentCondition.Property != "" { + contextValue = getContextValue(ec, segmentCondition.Property) + } + if segmentCondition.Operator == PercentageSplit { + var objectIds []string + if contextValue != nil { + // Try to get string representation of the context value + var strValue string + switch v := contextValue.(type) { + case string: + strValue = v + case *Value: + if v != nil && v.String != nil { + strValue = *v.String + } else { + return false + } + default: + return false + } + objectIds = []string{segmentKey, strValue} + } else if ec.Identity != nil { + objectIds = []string{segmentKey, ec.Identity.Key} + } else { + return false + } + if segmentCondition.Value != nil && segmentCondition.Value.String != nil { + floatValue, _ := strconv.ParseFloat(*segmentCondition.Value.String, 64) + return utils.GetHashedPercentageForObjectIds(objectIds, 1) <= floatValue + } + return false + } + if segmentCondition.Operator == IsNotSet { + return contextValue == nil + } + if segmentCondition.Operator == IsSet { + return contextValue != nil + } + if contextValue != nil { + return match(segmentCondition.Operator, ToString(contextValue), *segmentCondition.Value.String) + } + return false +} + +func getContextValue(ec *EngineEvaluationContext, property string) ContextValue { + if strings.HasPrefix(property, "$.") { + return getContextValueGetter(property)(ec) + } else if ec.Identity != nil { + if ec.Identity.Traits != nil { + value, exists := ec.Identity.Traits[property] + if exists { + return value + } + } + } + return nil +} + +// getContextValueGetter returns a cached function to retrieve a value from a map[string]any +// using either a JSONPath expression or a fallback trait key. +func getContextValueGetter(property string) func(ec *EngineEvaluationContext) any { + // First, try to parse the property as a JSONPath expression. + p, err := jp.ParseString(property) + if err == nil { + // If successful, create and cache a getter for the JSONPath. + getter := func(evalCtx *EngineEvaluationContext) any { + // Convert the struct to a map for JSONPath evaluation + data := map[string]interface{}{ + "environment": map[string]interface{}{ + "key": evalCtx.Environment.Key, + "name": evalCtx.Environment.Name, + }, + } + + if evalCtx.Identity != nil { + identityMap := map[string]interface{}{ + "identifier": evalCtx.Identity.Identifier, + "key": evalCtx.Identity.Key, + } + + if evalCtx.Identity.Traits != nil { + traitsMap := make(map[string]interface{}) + for k, v := range evalCtx.Identity.Traits { + if v != nil { + if v.String != nil { + traitsMap[k] = *v.String + } else if v.Bool != nil { + traitsMap[k] = *v.Bool + } else if v.Double != nil { + traitsMap[k] = *v.Double + } + } + } + identityMap["traits"] = traitsMap + } + + data["identity"] = identityMap + } + + // Use JSONPath to get the value + results := p.Get(data) + if len(results) > 0 { + return results[0] + } + return nil + } + return getter + } + + // If JSONPath parsing fails, return a getter that always returns nil. + return func(ec *EngineEvaluationContext) any { + return nil + } +} + +func ToString(contextValue ContextValue) string { + if s, ok := contextValue.(string); ok { + return s + } + // Handle *Value type + if v, ok := contextValue.(*Value); ok && v != nil { + if v.String != nil { + return *v.String + } + if v.Bool != nil { + return strconv.FormatBool(*v.Bool) + } + if v.Double != nil { + return strconv.FormatFloat(*v.Double, 'f', -1, 64) + } + } + return fmt.Sprint(contextValue) +} + +func match(c Operator, traitValue, conditionValue string) bool { + b1, e1 := strconv.ParseBool(traitValue) + b2, e2 := strconv.ParseBool(conditionValue) + if e1 == nil && e2 == nil { + return matchBool(c, b1, b2) + } + + i1, e1 := strconv.ParseInt(traitValue, 10, 64) + i2, e2 := strconv.ParseInt(conditionValue, 10, 64) + if e1 == nil && e2 == nil { + return matchInt(c, i1, i2) + } + + f1, e1 := strconv.ParseFloat(traitValue, 64) + f2, e2 := strconv.ParseFloat(conditionValue, 64) + if e1 == nil && e2 == nil { + return matchFloat(c, f1, f2) + } + if strings.HasSuffix(conditionValue, ":semver") { + conditionVersion, err := semver.Make(conditionValue[:len(conditionValue)-7]) + if err != nil { + return false + } + return matchSemver(c, traitValue, conditionVersion) + } + return matchString(c, traitValue, conditionValue) +} + +func matchSemver(c Operator, traitValue string, conditionVersion semver.Version) bool { + traitVersion, err := semver.Make(traitValue) + if err != nil { + return false + } + switch c { + case Equal: + return traitVersion.EQ(conditionVersion) + case GreaterThan: + return traitVersion.GT(conditionVersion) + case LessThan: + return traitVersion.LT(conditionVersion) + case LessThanInclusive: + return traitVersion.LTE(conditionVersion) + case GreaterThanInclusive: + return traitVersion.GE(conditionVersion) + case NotEqual: + return traitVersion.NE(conditionVersion) + } + return false +} + +func matchBool(c Operator, v1, v2 bool) bool { + var i1, i2 int64 + if v1 { + i1 = 1 + } + if v2 { + i2 = 1 + } + return matchInt(c, i1, i2) +} + +func matchInt(c Operator, v1, v2 int64) bool { + switch c { + case Equal: + return v1 == v2 + case GreaterThan: + return v1 > v2 + case LessThan: + return v1 < v2 + case LessThanInclusive: + return v1 <= v2 + case GreaterThanInclusive: + return v1 >= v2 + case NotEqual: + return v1 != v2 + } + return v1 == v2 +} + +func matchFloat(c Operator, v1, v2 float64) bool { + switch c { + case Equal: + return v1 == v2 + case GreaterThan: + return v1 > v2 + case LessThan: + return v1 < v2 + case LessThanInclusive: + return v1 <= v2 + case GreaterThanInclusive: + return v1 >= v2 + case NotEqual: + return v1 != v2 + } + return v1 == v2 +} + +func matchString(c Operator, v1, v2 string) bool { + switch c { + case Contains: + return strings.Contains(v1, v2) + case NotContains: + return !strings.Contains(v1, v2) + case In: + return slices.Contains(strings.Split(v2, ","), v1) + case Equal: + return v1 == v2 + case GreaterThan: + return v1 > v2 + case LessThan: + return v1 < v2 + case LessThanInclusive: + return v1 <= v2 + case GreaterThanInclusive: + return v1 >= v2 + case NotEqual: + return v1 != v2 + } + return v1 == v2 +} diff --git a/flagengine/engine_eval/evaluator_test.go b/flagengine/engine_eval/evaluator_test.go new file mode 100644 index 00000000..be294e6b --- /dev/null +++ b/flagengine/engine_eval/evaluator_test.go @@ -0,0 +1,810 @@ +package engine_eval_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" + "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" +) + +const ( + traitKey1 = "email" + traitValue1 = "user@example.com" + + traitKey2 = "num_purchase" + traitValue2 = "12" + + traitKey3 = "date_joined" + traitValue3 = "2021-01-01" +) + +// Helper function to create a Value pointer. +func stringValue(s string) *engine_eval.Value { + return &engine_eval.Value{String: &s} +} + +func boolValue(b bool) *engine_eval.Value { + return &engine_eval.Value{Bool: &b} +} + +func doubleValue(d float64) *engine_eval.Value { + return &engine_eval.Value{Double: &d} +} + +// Helper function to create string pointer. +func stringPtr(s string) *string { + return &s +} + +// Helper function to create evaluation context with traits. +func createEvaluationContext(traits map[string]*engine_eval.Value) *engine_eval.EngineEvaluationContext { + return &engine_eval.EngineEvaluationContext{ + Environment: engine_eval.EnvironmentContext{ + Key: "test-env", + Name: "Test Environment", + }, + Identity: &engine_eval.IdentityContext{ + Identifier: "test-user", + Key: "test-env_test-user", + Traits: traits, + }, + } +} + +// Helper function to create segment context. +func createSegmentContext(key, name string, rules []engine_eval.SegmentRule) *engine_eval.SegmentContext { + return &engine_eval.SegmentContext{ + Key: key, + Name: name, + Rules: rules, + } +} + +func TestIsContextInSegment(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + segmentContext *engine_eval.SegmentContext + evalContext *engine_eval.EngineEvaluationContext + expected bool + }{ + { + name: "empty segment rules returns false", + segmentContext: createSegmentContext("1", "empty_segment", []engine_eval.SegmentRule{}), + evalContext: createEvaluationContext(nil), + expected: false, + }, + { + name: "single condition matches", + segmentContext: createSegmentContext("2", "single_condition", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + }, + }, + }, + }), + evalContext: createEvaluationContext(map[string]*engine_eval.Value{ + traitKey1: stringValue(traitValue1), + }), + expected: true, + }, + { + name: "single condition does not match", + segmentContext: createSegmentContext("3", "single_condition_no_match", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + }, + }, + }, + }), + evalContext: createEvaluationContext(map[string]*engine_eval.Value{ + traitKey1: stringValue("different@example.com"), + }), + expected: false, + }, + { + name: "multiple conditions ALL - all match", + segmentContext: createSegmentContext("4", "multiple_conditions_all", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + }, + { + Operator: engine_eval.Equal, + Property: traitKey2, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue2)}, + }, + }, + }, + }), + evalContext: createEvaluationContext(map[string]*engine_eval.Value{ + traitKey1: stringValue(traitValue1), + traitKey2: stringValue(traitValue2), + }), + expected: true, + }, + { + name: "multiple conditions ALL - one does not match", + segmentContext: createSegmentContext("5", "multiple_conditions_all_fail", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + }, + { + Operator: engine_eval.Equal, + Property: traitKey2, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue2)}, + }, + }, + }, + }), + evalContext: createEvaluationContext(map[string]*engine_eval.Value{ + traitKey1: stringValue(traitValue1), + traitKey2: stringValue("different_value"), + }), + expected: false, + }, + { + name: "multiple conditions ANY - one matches", + segmentContext: createSegmentContext("6", "multiple_conditions_any", []engine_eval.SegmentRule{ + { + Type: engine_eval.Any, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + }, + { + Operator: engine_eval.Equal, + Property: traitKey2, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue2)}, + }, + }, + }, + }), + evalContext: createEvaluationContext(map[string]*engine_eval.Value{ + traitKey1: stringValue(traitValue1), + traitKey2: stringValue("different_value"), + }), + expected: true, + }, + { + name: "nested rules", + segmentContext: createSegmentContext("7", "nested_rules", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Rules: []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + }, + { + Operator: engine_eval.Equal, + Property: traitKey2, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue2)}, + }, + }, + }, + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey3, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue3)}, + }, + }, + }, + }, + }, + }), + evalContext: createEvaluationContext(map[string]*engine_eval.Value{ + traitKey1: stringValue(traitValue1), + traitKey2: stringValue(traitValue2), + traitKey3: stringValue(traitValue3), + }), + expected: true, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + result := engine_eval.IsContextInSegment(c.evalContext, c.segmentContext) + assert.Equal(t, c.expected, result) + }) + } +} + +func TestContextMatchesCondition(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + operator engine_eval.Operator + property string + conditionValue string + traitValue interface{} + expected bool + }{ + // String comparisons + {"equal strings match", engine_eval.Equal, traitKey1, "test", "test", true}, + {"equal strings don't match", engine_eval.Equal, traitKey1, "test", "different", false}, + {"not equal strings", engine_eval.NotEqual, traitKey1, "test", "different", true}, + {"not equal same strings", engine_eval.NotEqual, traitKey1, "test", "test", false}, + + // Numeric comparisons + {"greater than int", engine_eval.GreaterThan, traitKey2, "5", "10", true}, + {"greater than int false", engine_eval.GreaterThan, traitKey2, "10", "5", false}, + {"greater than equal", engine_eval.GreaterThan, traitKey2, "10", "10", false}, + {"greater than inclusive", engine_eval.GreaterThanInclusive, traitKey2, "10", "10", true}, + {"less than int", engine_eval.LessThan, traitKey2, "10", "5", true}, + {"less than int false", engine_eval.LessThan, traitKey2, "5", "10", false}, + {"less than inclusive", engine_eval.LessThanInclusive, traitKey2, "10", "10", true}, + + // Float comparisons + {"greater than float", engine_eval.GreaterThan, traitKey2, "5.5", "10.1", true}, + {"less than float", engine_eval.LessThan, traitKey2, "10.1", "5.5", true}, + + // Boolean comparisons + {"equal bool true", engine_eval.Equal, traitKey1, "true", "true", true}, + {"equal bool false", engine_eval.Equal, traitKey1, "false", "false", true}, + {"not equal bool", engine_eval.NotEqual, traitKey1, "true", "false", true}, + + // String operations + {"contains", engine_eval.Contains, traitKey1, "test", "testing", true}, + {"contains false", engine_eval.Contains, traitKey1, "xyz", "testing", false}, + {"not contains", engine_eval.NotContains, traitKey1, "xyz", "testing", true}, + {"not contains false", engine_eval.NotContains, traitKey1, "test", "testing", false}, + + // IN operator + {"in list first", engine_eval.In, traitKey1, "a,b,c", "a", true}, + {"in list middle", engine_eval.In, traitKey1, "a,b,c", "b", true}, + {"in list last", engine_eval.In, traitKey1, "a,b,c", "c", true}, + {"not in list", engine_eval.In, traitKey1, "a,b,c", "d", false}, + {"in single item", engine_eval.In, traitKey1, "test", "test", true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + condition := &engine_eval.Condition{ + Operator: c.operator, + Property: c.property, + Value: &engine_eval.ValueUnion{String: stringPtr(c.conditionValue)}, + } + + var traitValuePtr *engine_eval.Value + switch v := c.traitValue.(type) { + case string: + traitValuePtr = stringValue(v) + case bool: + traitValuePtr = boolValue(v) + case float64: + traitValuePtr = doubleValue(v) + default: + traitValuePtr = stringValue(fmt.Sprint(v)) + } + + evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + c.property: traitValuePtr, + }) + + // We need to access the internal function, so we'll test via IsContextInSegment + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{*condition}, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.Equal(t, c.expected, result) + }) + } +} + +func TestContextMatchesConditionIsSetAndIsNotSet(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + operator engine_eval.Operator + property string + hasProperty bool + expectedResult bool + }{ + {"IsSet with property", engine_eval.IsSet, "foo", true, true}, + {"IsSet without property", engine_eval.IsSet, "foo", false, false}, + {"IsNotSet with property", engine_eval.IsNotSet, "foo", true, false}, + {"IsNotSet without property", engine_eval.IsNotSet, "foo", false, true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + condition := &engine_eval.Condition{ + Operator: c.operator, + Property: c.property, + } + + var traits map[string]*engine_eval.Value + if c.hasProperty { + traits = map[string]*engine_eval.Value{ + c.property: stringValue("some_value"), + } + } + + evalContext := createEvaluationContext(traits) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{*condition}, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.Equal(t, c.expectedResult, result) + }) + } +} + +func TestContextMatchesConditionPercentageSplit(t *testing.T) { + cases := []struct { + name string + segmentSplitValue string + identityHashedPercentage float64 + expectedResult bool + }{ + {"10% split, 1% hash - should match", "10", 1.0, true}, + {"100% split, 50% hash - should match", "100", 50.0, true}, + {"0% split, 1% hash - should not match", "0", 1.0, false}, + {"10% split, 20% hash - should not match", "10", 20.0, false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + condition := &engine_eval.Condition{ + Operator: engine_eval.PercentageSplit, + Property: "", + Value: &engine_eval.ValueUnion{String: stringPtr(c.segmentSplitValue)}, + } + + evalContext := createEvaluationContext(nil) + + // Mock the hashing function + utils.MockSetHashedPercentageForObjectIds(func(_ []string, _ int) float64 { + return c.identityHashedPercentage + }) + defer utils.ResetMocks() + + segmentContext := createSegmentContext("test-segment", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{*condition}, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.Equal(t, c.expectedResult, result) + }) + } +} + +func TestGetContextValueIntegration(t *testing.T) { + t.Parallel() + + // Test getContextValue indirectly through IsContextInSegment + // This tests that the function works correctly in the context it's used + + t.Run("simple trait lookup works", func(t *testing.T) { + evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + "email": stringValue("test@example.com"), + }) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: "email", + Value: &engine_eval.ValueUnion{String: stringPtr("test@example.com")}, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + }) + + t.Run("JSONPath identity identifier works", func(t *testing.T) { + evalContext := createEvaluationContext(nil) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: "$.identity.identifier", + Value: &engine_eval.ValueUnion{String: stringPtr("test-user")}, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + }) + + t.Run("JSONPath environment key works", func(t *testing.T) { + evalContext := createEvaluationContext(nil) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: "$.environment.key", + Value: &engine_eval.ValueUnion{String: stringPtr("test-env")}, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + }) +} + +func TestToStringIntegration(t *testing.T) { + t.Parallel() + + // Test ToString indirectly through IsContextInSegment + // This tests that the function works correctly in the context it's used + + t.Run("string values work correctly", func(t *testing.T) { + evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + "test_prop": stringValue("test_string"), + }) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: "test_prop", + Value: &engine_eval.ValueUnion{String: stringPtr("test_string")}, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + }) + + t.Run("boolean values work correctly", func(t *testing.T) { + evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + "test_prop": boolValue(true), + }) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: "test_prop", + Value: &engine_eval.ValueUnion{String: stringPtr("true")}, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + }) + + t.Run("numeric values work correctly", func(t *testing.T) { + evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + "test_prop": doubleValue(123.45), + }) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: "test_prop", + Value: &engine_eval.ValueUnion{String: stringPtr("123.45")}, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + }) +} + +func TestSemverComparisons(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + operator engine_eval.Operator + traitValue string + conditionValue string + expected bool + }{ + // Equal + {"semver equal match", engine_eval.Equal, "1.2.3", "1.2.3:semver", true}, + {"semver equal no match", engine_eval.Equal, "1.2.4", "1.2.3:semver", false}, + {"semver equal invalid trait", engine_eval.Equal, "not_a_semver", "1.2.3:semver", false}, + + // Not Equal + {"semver not equal same", engine_eval.NotEqual, "1.0.0", "1.0.0:semver", false}, + {"semver not equal different", engine_eval.NotEqual, "1.0.1", "1.0.0:semver", true}, + + // Greater Than + {"semver greater than true", engine_eval.GreaterThan, "1.0.1", "1.0.0:semver", true}, + {"semver greater than false", engine_eval.GreaterThan, "1.0.1", "1.1.0:semver", false}, + {"semver greater than equal", engine_eval.GreaterThan, "1.0.1", "1.0.1:semver", false}, + {"semver greater than with prerelease", engine_eval.GreaterThan, "1.2.4", "1.2.3-pre.2+build.4:semver", true}, + + // Less Than + {"semver less than false", engine_eval.LessThan, "1.0.1", "1.0.0:semver", false}, + {"semver less than true", engine_eval.LessThan, "1.0.1", "1.1.0:semver", true}, + {"semver less than equal", engine_eval.LessThan, "1.0.1", "1.0.1:semver", false}, + + // Greater Than Inclusive + {"semver gte true", engine_eval.GreaterThanInclusive, "1.0.1", "1.0.0:semver", true}, + {"semver gte false", engine_eval.GreaterThanInclusive, "1.0.1", "1.2.0:semver", false}, + {"semver gte equal", engine_eval.GreaterThanInclusive, "1.0.1", "1.0.1:semver", true}, + + // Less Than Inclusive + {"semver lte true", engine_eval.LessThanInclusive, "1.0.0", "1.0.1:semver", true}, + {"semver lte equal", engine_eval.LessThanInclusive, "1.0.0", "1.0.0:semver", true}, + {"semver lte false", engine_eval.LessThanInclusive, "1.0.1", "1.0.0:semver", false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + condition := &engine_eval.Condition{ + Operator: c.operator, + Property: "version", + Value: &engine_eval.ValueUnion{String: stringPtr(c.conditionValue)}, + } + + evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + "version": stringValue(c.traitValue), + }) + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{*condition}, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.Equal(t, c.expected, result) + }) + } +} + +func TestComplexSegmentRules(t *testing.T) { + t.Parallel() + + t.Run("conditions and nested rules", func(t *testing.T) { + // Test a segment with both conditions and nested rules + segmentContext := createSegmentContext("complex", "complex_segment", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + }, + }, + Rules: []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey2, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue2)}, + }, + }, + }, + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey3, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue3)}, + }, + }, + }, + }, + }, + }) + + // Should match when all conditions are met + evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + traitKey1: stringValue(traitValue1), + traitKey2: stringValue(traitValue2), + traitKey3: stringValue(traitValue3), + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + + // Should not match when one condition fails + evalContextPartial := createEvaluationContext(map[string]*engine_eval.Value{ + traitKey1: stringValue(traitValue1), + traitKey2: stringValue(traitValue2), + // Missing traitKey3 + }) + + result = engine_eval.IsContextInSegment(evalContextPartial, segmentContext) + assert.False(t, result) + }) + + t.Run("NONE rule type", func(t *testing.T) { + segmentContext := createSegmentContext("none_rule", "none_segment", []engine_eval.SegmentRule{ + { + Type: engine_eval.None, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: traitKey1, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + }, + { + Operator: engine_eval.Equal, + Property: traitKey2, + Value: &engine_eval.ValueUnion{String: stringPtr(traitValue2)}, + }, + }, + }, + }) + + // Should match when no conditions are met (NONE rule) + evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + traitKey1: stringValue("different1"), + traitKey2: stringValue("different2"), + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) + + // Should not match when any condition is met + evalContextWithMatch := createEvaluationContext(map[string]*engine_eval.Value{ + traitKey1: stringValue(traitValue1), // This matches + traitKey2: stringValue("different2"), + }) + + result = engine_eval.IsContextInSegment(evalContextWithMatch, segmentContext) + assert.False(t, result) + }) +} + +func TestEdgeCases(t *testing.T) { + t.Parallel() + + t.Run("no identity context", func(t *testing.T) { + evalContext := &engine_eval.EngineEvaluationContext{ + Environment: engine_eval.EnvironmentContext{ + Key: "test-env", + Name: "Test Environment", + }, + Identity: nil, // No identity + } + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Equal, + Property: "some_trait", + Value: &engine_eval.ValueUnion{String: stringPtr("value")}, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.False(t, result) + }) + + t.Run("empty traits map", func(t *testing.T) { + evalContext := &engine_eval.EngineEvaluationContext{ + Environment: engine_eval.EnvironmentContext{ + Key: "test-env", + Name: "Test Environment", + }, + Identity: &engine_eval.IdentityContext{ + Identifier: "test-user", + Key: "test-env_test-user", + Traits: nil, // No traits + }, + } + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.IsNotSet, + Property: "missing_trait", + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) // IsNotSet should return true for missing trait + }) + + t.Run("percentage split without identity", func(t *testing.T) { + evalContext := &engine_eval.EngineEvaluationContext{ + Environment: engine_eval.EnvironmentContext{ + Key: "test-env", + Name: "Test Environment", + }, + Identity: nil, + } + + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.PercentageSplit, + Property: "", + Value: &engine_eval.ValueUnion{String: stringPtr("50")}, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.False(t, result) // Should fail without identity + }) +} diff --git a/flagengine/segments/evaluator.go b/flagengine/segments/evaluator.go index f0f22857..3a2dbca3 100644 --- a/flagengine/segments/evaluator.go +++ b/flagengine/segments/evaluator.go @@ -1,14 +1,8 @@ package segments import ( - "strconv" - "strings" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" - "github.com/blang/semver/v4" - "golang.org/x/exp/slices" ) func EvaluateIdentityInSegment( @@ -16,196 +10,5 @@ func EvaluateIdentityInSegment( segment *SegmentModel, overrideTraits ...*traits.TraitModel, ) bool { - if len(segment.Rules) == 0 { - return false - } - - traits := identity.IdentityTraits - if len(overrideTraits) > 0 { - traits = overrideTraits - } - - identityHashKey := identity.CompositeKey() - if identity.DjangoID != 0 { - identityHashKey = strconv.Itoa(identity.DjangoID) - } - for _, rule := range segment.Rules { - if !traitsMatchSegmentRule(traits, rule, segment.ID, identityHashKey) { - return false - } - } - return true } - -func traitsMatchSegmentRule( - identityTraits []*traits.TraitModel, - rule *SegmentRuleModel, - segmentID int, - identityID string, -) bool { - conditions := make([]bool, len(rule.Conditions)) - for i, c := range rule.Conditions { - conditions[i] = traitsMatchSegmentCondition(identityTraits, c, segmentID, identityID) - } - matchesConditions := rule.MatchingFunction()(conditions) || len(rule.Conditions) == 0 - - rules := make([]bool, len(rule.Rules)) - for i, r := range rule.Rules { - rules[i] = traitsMatchSegmentRule(identityTraits, r, segmentID, identityID) - } - - return matchesConditions && utils.All(rules) -} - -func traitsMatchSegmentCondition( - identityTraits []*traits.TraitModel, - condition *SegmentConditionModel, - segmentID int, - identityID string, -) bool { - if condition.Operator == PercentageSplit { - floatValue, _ := strconv.ParseFloat(condition.Value, 64) - return utils.GetHashedPercentageForObjectIds([]string{strconv.Itoa(segmentID), identityID}, 1) <= floatValue - } - var matchedTraitValue *string - for _, trait := range identityTraits { - if trait.TraitKey == condition.Property { - matchedTraitValue = &trait.TraitValue - } - } - - if condition.Operator == IsNotSet { - return matchedTraitValue == nil - } - if condition.Operator == IsSet { - return matchedTraitValue != nil - } - - if matchedTraitValue != nil { - return condition.MatchesTraitValue(*matchedTraitValue) - } - return false -} - -func match(c ConditionOperator, traitValue, conditionValue string) bool { - b1, e1 := strconv.ParseBool(traitValue) - b2, e2 := strconv.ParseBool(conditionValue) - if e1 == nil && e2 == nil { - return matchBool(c, b1, b2) - } - - i1, e1 := strconv.ParseInt(traitValue, 10, 64) - i2, e2 := strconv.ParseInt(conditionValue, 10, 64) - if e1 == nil && e2 == nil { - return matchInt(c, i1, i2) - } - - f1, e1 := strconv.ParseFloat(traitValue, 64) - f2, e2 := strconv.ParseFloat(conditionValue, 64) - if e1 == nil && e2 == nil { - return matchFloat(c, f1, f2) - } - if strings.HasSuffix(conditionValue, ":semver") { - conditionVersion, err := semver.Make(conditionValue[:len(conditionValue)-7]) - if err != nil { - return false - } - return matchSemver(c, traitValue, conditionVersion) - } - - return matchString(c, traitValue, conditionValue) -} - -func matchSemver(c ConditionOperator, traitValue string, conditionVersion semver.Version) bool { - traitVersion, err := semver.Make(traitValue) - if err != nil { - return false - } - switch c { - case Equal: - return traitVersion.EQ(conditionVersion) - case GreaterThan: - return traitVersion.GT(conditionVersion) - case LessThan: - return traitVersion.LT(conditionVersion) - case LessThanInclusive: - return traitVersion.LTE(conditionVersion) - case GreaterThanInclusive: - return traitVersion.GE(conditionVersion) - case NotEqual: - return traitVersion.NE(conditionVersion) - } - return false -} - -func matchBool(c ConditionOperator, v1, v2 bool) bool { - var i1, i2 int64 - if v1 { - i1 = 1 - } - if v2 { - i2 = 1 - } - return matchInt(c, i1, i2) -} - -func matchInt(c ConditionOperator, v1, v2 int64) bool { - switch c { - case Equal: - return v1 == v2 - case GreaterThan: - return v1 > v2 - case LessThan: - return v1 < v2 - case LessThanInclusive: - return v1 <= v2 - case GreaterThanInclusive: - return v1 >= v2 - case NotEqual: - return v1 != v2 - } - return v1 == v2 -} - -func matchFloat(c ConditionOperator, v1, v2 float64) bool { - switch c { - case Equal: - return v1 == v2 - case GreaterThan: - return v1 > v2 - case LessThan: - return v1 < v2 - case LessThanInclusive: - return v1 <= v2 - case GreaterThanInclusive: - return v1 >= v2 - case NotEqual: - return v1 != v2 - } - return v1 == v2 -} - -func matchString(c ConditionOperator, v1, v2 string) bool { - switch c { - case Contains: - return strings.Contains(v1, v2) - case NotContains: - return !strings.Contains(v1, v2) - case In: - return slices.Contains(strings.Split(v2, ","), v1) - case Equal: - return v1 == v2 - case GreaterThan: - return v1 > v2 - case LessThan: - return v1 < v2 - case LessThanInclusive: - return v1 <= v2 - case GreaterThanInclusive: - return v1 >= v2 - case NotEqual: - return v1 != v2 - } - return v1 == v2 -} diff --git a/flagengine/segments/evaluator_test.go b/flagengine/segments/evaluator_test.go deleted file mode 100644 index 9b18dc28..00000000 --- a/flagengine/segments/evaluator_test.go +++ /dev/null @@ -1,503 +0,0 @@ -package segments_test - -import ( - "fmt" - "strconv" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils/fixtures" -) - -const ( - trait_key_1 = "email" - trait_value_1 = "user@example.com" - - trait_key_2 = "num_purchase" - trait_value_2 = "12" - - trait_key_3 = "date_joined" - trait_value_3 = "2021-01-01" -) - -var ( - empty_segment = &segments.SegmentModel{ID: 1, Name: "empty_segment"} - segment_single_condition = &segments.SegmentModel{ - ID: 2, - Name: "segment_one_condition", - Rules: []*segments.SegmentRuleModel{ - { - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{ - { - Operator: segments.Equal, - Property: trait_key_1, - Value: trait_value_1, - }, - }, - }, - }, - } - segment_multiple_conditions_all = &segments.SegmentModel{ - ID: 3, - Name: "segment_multiple_conditions_all", - Rules: []*segments.SegmentRuleModel{ - { - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{ - { - Operator: segments.Equal, - Property: trait_key_1, - Value: trait_value_1, - }, - { - Operator: segments.Equal, - Property: trait_key_2, - Value: trait_value_2, - }, - }, - }, - }, - } - segment_multiple_conditions_any = &segments.SegmentModel{ - ID: 4, - Name: "segment_multiple_conditions_all", - Rules: []*segments.SegmentRuleModel{ - { - Type: segments.Any, - Conditions: []*segments.SegmentConditionModel{{ - Operator: segments.Equal, - Property: trait_key_1, - Value: trait_value_1, - }, - { - Operator: segments.Equal, - Property: trait_key_2, - Value: trait_value_2, - }, - }, - }, - }, - } - segment_nested_rules = &segments.SegmentModel{ - ID: 5, - Name: "segment_nested_rules_all", - Rules: []*segments.SegmentRuleModel{ - { - Type: segments.All, - Rules: []*segments.SegmentRuleModel{ - { - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{ - { - Operator: segments.Equal, - Property: trait_key_1, - Value: trait_value_1, - }, - { - Operator: segments.Equal, - Property: trait_key_2, - Value: trait_value_2, - }, - }, - }, - { - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{ - { - Operator: segments.Equal, - Property: trait_key_3, - Value: trait_value_3, - }, - }, - }, - }, - }, - }, - } - segment_conditions_and_nested_rules = &segments.SegmentModel{ - ID: 6, - Name: "segment_multiple_conditions_all_and_nested_rules", - Rules: []*segments.SegmentRuleModel{ - { - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{ - { - Operator: segments.Equal, - Property: trait_key_1, - Value: trait_value_1, - }, - }, - Rules: []*segments.SegmentRuleModel{ - { - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{ - { - Operator: segments.Equal, - Property: trait_key_2, - Value: trait_value_2, - }, - }, - }, - { - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{ - { - Operator: segments.Equal, - Property: trait_key_3, - Value: trait_value_3, - }, - }, - }, - }, - }, - }, - } -) - -func TestIdentityInSegment(t *testing.T) { - t.Parallel() - - cases := []struct { - segment *segments.SegmentModel - identityTraits []*traits.TraitModel - expected bool - }{ - {empty_segment, nil, false}, - {segment_single_condition, nil, false}, - { - segment_single_condition, - []*traits.TraitModel{{TraitKey: trait_key_1, TraitValue: trait_value_1}}, - true, - }, - {segment_multiple_conditions_all, nil, false}, - { - segment_multiple_conditions_all, - []*traits.TraitModel{{TraitKey: trait_key_1, TraitValue: trait_value_1}}, - false, - }, - { - segment_multiple_conditions_all, - []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - {TraitKey: trait_key_2, TraitValue: trait_value_2}, - }, - true, - }, - {segment_multiple_conditions_any, nil, false}, - { - segment_multiple_conditions_any, - []*traits.TraitModel{{TraitKey: trait_key_1, TraitValue: trait_value_1}}, - true, - }, - { - segment_multiple_conditions_any, - []*traits.TraitModel{{TraitKey: trait_key_2, TraitValue: trait_value_2}}, - true, - }, - { - segment_multiple_conditions_all, - []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - {TraitKey: trait_key_2, TraitValue: trait_value_2}, - }, - true, - }, - {segment_nested_rules, nil, false}, - { - segment_nested_rules, - []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - }, - false, - }, - { - segment_nested_rules, - []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - {TraitKey: trait_key_2, TraitValue: trait_value_2}, - {TraitKey: trait_key_3, TraitValue: trait_value_3}, - }, - true, - }, - {segment_conditions_and_nested_rules, nil, false}, - { - segment_conditions_and_nested_rules, - []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - }, - false, - }, - { - segment_conditions_and_nested_rules, - []*traits.TraitModel{ - {TraitKey: trait_key_1, TraitValue: trait_value_1}, - {TraitKey: trait_key_2, TraitValue: trait_value_2}, - {TraitKey: trait_key_3, TraitValue: trait_value_3}, - }, - true, - }, - } - - for i, c := range cases { - t.Run(strconv.Itoa(i), func(t *testing.T) { - doTestIdentityInSegment(t, c.segment, c.identityTraits, c.expected) - }) - } -} - -func doTestIdentityInSegment(t *testing.T, segment *segments.SegmentModel, identityTraits []*traits.TraitModel, expected bool) { - t.Helper() - - identity := &identities.IdentityModel{ - Identifier: "foo", - IdentityTraits: identityTraits, - EnvironmentAPIKey: "api-key", - } - - assert.Equal(t, expected, segments.EvaluateIdentityInSegment(identity, segment)) -} - -func TestIdentityInSegmentPercentageSplit(t *testing.T) { - cases := []struct { - segmentSplitValue int - identityHashedPercentage int - expectedResult bool - }{ - {10, 1, true}, - {100, 50, true}, - {0, 1, false}, - {10, 20, false}, - } - - _, _, _, _, identity := fixtures.GetFixtures() - - for i, c := range cases { - t.Run(strconv.Itoa(i), func(t *testing.T) { - cond := &segments.SegmentConditionModel{ - Operator: segments.PercentageSplit, - Value: strconv.Itoa(c.segmentSplitValue), - } - rule := &segments.SegmentRuleModel{ - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{cond}, - } - segment := &segments.SegmentModel{ID: 1, Name: "% split", Rules: []*segments.SegmentRuleModel{rule}} - - utils.MockSetHashedPercentageForObjectIds(func(_ []string, _ int) float64 { - return float64(c.identityHashedPercentage) - }) - result := segments.EvaluateIdentityInSegment(identity, segment) - - assert.Equal(t, c.expectedResult, result) - }) - } - utils.ResetMocks() -} - -func TestIdentityInSegmentPercentageSplitUsesDjangoID(t *testing.T) { - cases := []struct { - identity *identities.IdentityModel - expectedResult bool - }{ - {&identities.IdentityModel{ - DjangoID: 1, - Identifier: "Test", - EnvironmentAPIKey: "key", - }, false}, - {&identities.IdentityModel{ - Identifier: "Test", - EnvironmentAPIKey: "key", - }, true}, - } - - for i, c := range cases { - t.Run(strconv.Itoa(i), func(t *testing.T) { - cond := &segments.SegmentConditionModel{ - Operator: segments.PercentageSplit, - Value: "50", - } - rule := &segments.SegmentRuleModel{ - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{cond}, - } - segment := &segments.SegmentModel{ID: 1, Name: "% split", Rules: []*segments.SegmentRuleModel{rule}} - - result := segments.EvaluateIdentityInSegment(c.identity, segment) - - assert.Equal(t, result, c.expectedResult) - }) - } -} - -func TestIdentityInSegmentIsSetAndIsNotSet(t *testing.T) { - cases := []struct { - operator segments.ConditionOperator - property string - identityTraits []*traits.TraitModel - expectedResult bool - }{ - {segments.IsSet, "foo", []*traits.TraitModel{{TraitKey: "foo", TraitValue: "bar"}}, true}, - {segments.IsSet, "foo", []*traits.TraitModel{{TraitKey: "not_foo", TraitValue: "bar"}}, false}, - {segments.IsSet, "foo", []*traits.TraitModel{}, false}, - {segments.IsNotSet, "foo", []*traits.TraitModel{}, true}, - {segments.IsNotSet, "foo", []*traits.TraitModel{{TraitKey: "foo", TraitValue: "bar"}}, false}, - } - - for i, c := range cases { - t.Run(strconv.Itoa(i), func(t *testing.T) { - cond := &segments.SegmentConditionModel{ - Operator: c.operator, - Property: c.property, - } - rule := &segments.SegmentRuleModel{ - Type: segments.All, - Conditions: []*segments.SegmentConditionModel{cond}, - } - segment := &segments.SegmentModel{ID: 1, Name: "IsSet or IsNot", Rules: []*segments.SegmentRuleModel{rule}} - doTestIdentityInSegment(t, segment, c.identityTraits, c.expectedResult) - }) - } -} - -func TestSegmentConditionMatchesTraitValue(t *testing.T) { - cases := []struct { - operator segments.ConditionOperator - traitValue interface{} - conditionValue string - expectedResult bool - }{ - {segments.Equal, "bar", "bar", true}, - {segments.Equal, "bar", "baz", false}, - {segments.Equal, 1, "1", true}, - {segments.Equal, 1, "2", false}, - {segments.Equal, true, "true", true}, - {segments.Equal, false, "false", true}, - {segments.Equal, false, "true", false}, - {segments.Equal, true, "false", false}, - {segments.Equal, 1.23, "1.23", true}, - {segments.Equal, 1.23, "4.56", false}, - {segments.GreaterThan, 2, "1", true}, - {segments.GreaterThan, 1, "1", false}, - {segments.GreaterThan, 0, "1", false}, - {segments.GreaterThan, 2.1, "2.0", true}, - {segments.GreaterThan, 2.1, "2.1", false}, - {segments.GreaterThan, 2.0, "2.1", false}, - {segments.GreaterThanInclusive, 2, "1", true}, - {segments.GreaterThanInclusive, 1, "1", true}, - {segments.GreaterThanInclusive, 0, "1", false}, - {segments.GreaterThanInclusive, 2.1, "2.0", true}, - {segments.GreaterThanInclusive, 2.1, "2.1", true}, - {segments.GreaterThanInclusive, 2.0, "2.1", false}, - {segments.LessThan, 1, "2", true}, - {segments.LessThan, 1, "1", false}, - {segments.LessThan, 1, "0", false}, - {segments.LessThan, 2.0, "2.1", true}, - {segments.LessThan, 2.1, "2.1", false}, - {segments.LessThan, 2.1, "2.0", false}, - {segments.LessThanInclusive, 1, "2", true}, - {segments.LessThanInclusive, 1, "1", true}, - {segments.LessThanInclusive, 1, "0", false}, - {segments.LessThanInclusive, 2.0, "2.1", true}, - {segments.LessThanInclusive, 2.1, "2.1", true}, - {segments.LessThanInclusive, 2.1, "2.0", false}, - {segments.NotEqual, "bar", "baz", true}, - {segments.NotEqual, "bar", "bar", false}, - {segments.NotEqual, 1, "2", true}, - {segments.NotEqual, 1, "1", false}, - {segments.NotEqual, true, "false", true}, - {segments.NotEqual, false, "true", true}, - {segments.NotEqual, false, "false", false}, - {segments.NotEqual, true, "true", false}, - {segments.Contains, "bar", "b", true}, - {segments.Contains, "bar", "bar", true}, - {segments.Contains, "bar", "baz", false}, - {segments.NotContains, "bar", "b", false}, - {segments.NotContains, "bar", "bar", false}, - {segments.NotContains, "bar", "baz", true}, - {segments.Regex, "foo", "[a-z]+", true}, - {segments.Regex, "FOO", "[a-z]+", false}, - - // Semver - {segments.Equal, "1.2.3", "1.2.3:semver", true}, - {segments.Equal, "1.2.4", "1.2.3:semver", false}, - {segments.Equal, "not_a_semver", "1.2.3:semver", false}, - - {segments.NotEqual, "1.0.0", "1.0.0:semver", false}, - {segments.NotEqual, "1.0.1", "1.0.0:semver", true}, - - {segments.GreaterThan, "1.0.1", "1.0.0:semver", true}, - {segments.GreaterThan, "1.0.1", "1.1.0:semver", false}, - {segments.GreaterThan, "1.0.1", "1.0.1:semver", false}, - {segments.GreaterThan, "1.2.4", "1.2.3-pre.2+build.4:semver", true}, - - {segments.LessThan, "1.0.1", "1.0.0:semver", false}, - {segments.LessThan, "1.0.1", "1.1.0:semver", true}, - {segments.LessThan, "1.0.1", "1.0.1:semver", false}, - {segments.LessThan, "1.2.4", "1.2.3-pre.2+build.4:semver", false}, - - {segments.GreaterThanInclusive, "1.0.1", "1.0.0:semver", true}, - {segments.GreaterThanInclusive, "1.0.1", "1.2.0:semver", false}, - {segments.GreaterThanInclusive, "1.0.1", "1.0.1:semver", true}, - {segments.LessThanInclusive, "1.0.0", "1.0.1:semver", true}, - {segments.LessThanInclusive, "1.0.0", "1.0.0:semver", true}, - {segments.LessThanInclusive, "1.0.1", "1.0.0:semver", false}, - - // Modulo - {segments.Modulo, 1, "2|0", false}, - {segments.Modulo, 2, "2|0", true}, - {segments.Modulo, 1.1, "2.1|1.1", true}, - {segments.Modulo, 3, "2|0", false}, - {segments.Modulo, 34.2, "4|3", false}, - {segments.Modulo, 35.0, "4|3", true}, - {segments.Modulo, "foo", "4|3", false}, - {segments.Modulo, "1.0.0", "4|3", false}, - {segments.Modulo, false, "4|3", false}, - - // In - {segments.In, "foo", "", false}, - {segments.In, "foo", "foo,bar", true}, - {segments.In, "bar", "foo,bar", true}, - {segments.In, "ba", "foo,bar", false}, - {segments.In, "foo", "foo", true}, - {segments.In, 1, "1,2,3,4", true}, - {segments.In, 1, "", false}, - {segments.In, 1, "1", true}, - } - - for _, c := range cases { - trStr := fmt.Sprint(c.traitValue) - t.Run(trStr+" "+string(c.operator)+" "+c.conditionValue, func(t *testing.T) { - cond := &segments.SegmentConditionModel{ - Operator: c.operator, - Property: "foo", - Value: c.conditionValue, - } - assert.Equal(t, c.expectedResult, cond.MatchesTraitValue(trStr)) - }) - } -} - -func TestSegmentRuleNone(t *testing.T) { - cases := []struct { - iterable []bool - expectedResult bool - }{ - {[]bool{}, true}, - {[]bool{false}, true}, - {[]bool{false, false}, true}, - {[]bool{false, true}, false}, - {[]bool{true, true}, false}, - } - - for i, c := range cases { - t.Run(strconv.Itoa(i), func(t *testing.T) { - assert.Equal(t, c.expectedResult, utils.None(c.iterable)) - }) - } -} diff --git a/flagengine/segments/models.go b/flagengine/segments/models.go index 4395612f..f2b842de 100644 --- a/flagengine/segments/models.go +++ b/flagengine/segments/models.go @@ -1,11 +1,6 @@ package segments import ( - "math" - "regexp" - "strconv" - "strings" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" ) @@ -16,47 +11,6 @@ type SegmentConditionModel struct { Property string `json:"property_"` } -func (m *SegmentConditionModel) MatchesTraitValue(traitValue string) bool { - switch m.Operator { - case Modulo: - return m.modulo(traitValue) - case Regex: - return m.regex(traitValue) - default: - return match(m.Operator, traitValue, m.Value) - } -} - -func (m *SegmentConditionModel) regex(traitValue string) bool { - match, err := regexp.Match(m.Value, []byte(traitValue)) - if err != nil { - return false - } - return match -} - -func (m *SegmentConditionModel) modulo(traitValue string) bool { - values := strings.Split(m.Value, "|") - if len(values) != 2 { - return false - } - - divisor, err := strconv.ParseFloat(values[0], 64) - if err != nil { - return false - } - - remainder, err := strconv.ParseFloat(values[1], 64) - if err != nil { - return false - } - traitValueFloat, err := strconv.ParseFloat(traitValue, 64) - if err != nil { - return false - } - return math.Mod(traitValueFloat, divisor) == remainder -} - type SegmentRuleModel struct { Type RuleType `json:"type"` Rules []*SegmentRuleModel From b00859122c5b704f03a3f5fc8aa65e231dd2e1c0 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 1 Oct 2025 13:21:33 +0530 Subject: [PATCH 08/56] rename --- client.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client.go b/client.go index 11d2f1f3..fc3317b6 100644 --- a/client.go +++ b/client.go @@ -27,8 +27,8 @@ type Client struct { apiKey string config config - environment atomic.Value - evaluationContext atomic.Value + environment atomic.Value + engineEvaluationContext atomic.Value analyticsProcessor *AnalyticsProcessor realtime *realtime @@ -142,7 +142,7 @@ func NewClient(apiKey string, options ...Option) *Client { c.environment.Store(env) // Update evaluation context atomically for offline environment engineEvalCtx := engine_eval.MapEnvironmentDocumentToEvaluationContext(env) - c.evaluationContext.Store(&engineEvalCtx) + c.engineEvaluationContext.Store(&engineEvalCtx) } if c.config.localEvaluation { @@ -231,7 +231,7 @@ func (c *Client) GetIdentityFlags(ctx context.Context, identifier string, traits // Returns an array of segments that the given identity is part of. func (c *Client) GetIdentitySegments(identifier string, traits []*Trait) ([]*segments.SegmentModel, error) { - if evalCtx, ok := c.evaluationContext.Load().(*engine_eval.EngineEvaluationContext); ok { + if evalCtx, ok := c.engineEvaluationContext.Load().(*engine_eval.EngineEvaluationContext); ok { engineEvalCtx := engine_eval.MapContextAndIdentityDataToContext(*evalCtx, identifier, traits) result := flagengine.GetEvaluationResult(&engineEvalCtx) @@ -337,7 +337,7 @@ func (c *Client) GetIdentityFlagsFromAPI(ctx context.Context, identifier string, } func (c *Client) getIdentityFlagsFromEnvironment(identifier string, traits []*Trait) (Flags, error) { - evalCtx, ok := c.evaluationContext.Load().(*engine_eval.EngineEvaluationContext) + evalCtx, ok := c.engineEvaluationContext.Load().(*engine_eval.EngineEvaluationContext) if !ok { return Flags{}, fmt.Errorf("flagsmith: local environment has not yet been updated") } @@ -347,7 +347,7 @@ func (c *Client) getIdentityFlagsFromEnvironment(identifier string, traits []*Tr } func (c *Client) getEnvironmentFlagsFromEnvironment() (Flags, error) { - evalCtx, ok := c.evaluationContext.Load().(*engine_eval.EngineEvaluationContext) + evalCtx, ok := c.engineEvaluationContext.Load().(*engine_eval.EngineEvaluationContext) if !ok { return Flags{}, fmt.Errorf("flagsmith: local environment has not yet been updated") } @@ -458,7 +458,7 @@ func (c *Client) UpdateEnvironment(ctx context.Context) error { // Update evaluation context atomically when environment changes engineEvalCtx := engine_eval.MapEnvironmentDocumentToEvaluationContext(&env) - c.evaluationContext.Store(&engineEvalCtx) + c.engineEvaluationContext.Store(&engineEvalCtx) if isNew { c.log.Info("environment updated", "environment", env.APIKey, "updated_at", env.UpdatedAt) From fa8b3fdc7ab4d0ede2c38248323132969937fbda Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 1 Oct 2025 13:22:27 +0530 Subject: [PATCH 09/56] remove makefile trigger --- .github/workflows/go.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 0d19fdf4..0780e34e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -30,9 +30,6 @@ jobs: with: submodules: recursive - - name: Build evaluation context struct - run: make generate-evaluation-context - - name: Get dependencies run: | go get -v -t -d ./... From ba2074eb0489dc96e2f54d4d2ce519c5fa18ca1f Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 1 Oct 2025 14:19:48 +0530 Subject: [PATCH 10/56] misc --- client.go | 4 ---- flagengine/engine_eval/evaluator.go | 36 +---------------------------- 2 files changed, 1 insertion(+), 39 deletions(-) diff --git a/client.go b/client.go index fc3317b6..3b8c0be8 100644 --- a/client.go +++ b/client.go @@ -140,7 +140,6 @@ func NewClient(apiKey string, options ...Option) *Client { if c.offlineHandler != nil { env := c.offlineHandler.GetEnvironment() c.environment.Store(env) - // Update evaluation context atomically for offline environment engineEvalCtx := engine_eval.MapEnvironmentDocumentToEvaluationContext(env) c.engineEvaluationContext.Store(&engineEvalCtx) } @@ -234,8 +233,6 @@ func (c *Client) GetIdentitySegments(identifier string, traits []*Trait) ([]*seg if evalCtx, ok := c.engineEvaluationContext.Load().(*engine_eval.EngineEvaluationContext); ok { engineEvalCtx := engine_eval.MapContextAndIdentityDataToContext(*evalCtx, identifier, traits) result := flagengine.GetEvaluationResult(&engineEvalCtx) - - // Use the new mapper to convert evaluation result segments to SegmentModel return engine_eval.MapEvaluationResultSegmentsToSegmentModels(&result), nil } return nil, &FlagsmithClientError{msg: "flagsmith: Local evaluation required to obtain identity segments"} @@ -456,7 +453,6 @@ func (c *Client) UpdateEnvironment(ctx context.Context) error { } c.environment.Store(&env) - // Update evaluation context atomically when environment changes engineEvalCtx := engine_eval.MapEnvironmentDocumentToEvaluationContext(&env) c.engineEvaluationContext.Store(&engineEvalCtx) diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index e11dc5a9..0b45ff67 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -121,41 +121,7 @@ func getContextValueGetter(property string) func(ec *EngineEvaluationContext) an if err == nil { // If successful, create and cache a getter for the JSONPath. getter := func(evalCtx *EngineEvaluationContext) any { - // Convert the struct to a map for JSONPath evaluation - data := map[string]interface{}{ - "environment": map[string]interface{}{ - "key": evalCtx.Environment.Key, - "name": evalCtx.Environment.Name, - }, - } - - if evalCtx.Identity != nil { - identityMap := map[string]interface{}{ - "identifier": evalCtx.Identity.Identifier, - "key": evalCtx.Identity.Key, - } - - if evalCtx.Identity.Traits != nil { - traitsMap := make(map[string]interface{}) - for k, v := range evalCtx.Identity.Traits { - if v != nil { - if v.String != nil { - traitsMap[k] = *v.String - } else if v.Bool != nil { - traitsMap[k] = *v.Bool - } else if v.Double != nil { - traitsMap[k] = *v.Double - } - } - } - identityMap["traits"] = traitsMap - } - - data["identity"] = identityMap - } - - // Use JSONPath to get the value - results := p.Get(data) + results := p.Get(evalCtx) if len(results) > 0 { return results[0] } From 49db0d25316dac58e3e00b8e35cdeb597fd18498 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 1 Oct 2025 14:20:41 +0530 Subject: [PATCH 11/56] squash! --- .github/workflows/go.yml | 1 - flagengine/engine_eval/evaluator.go | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 0780e34e..10e41796 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -7,7 +7,6 @@ on: jobs: build: - if: github.event.pull_request.draft == false name: Build runs-on: ubuntu-latest diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index 0b45ff67..3d6a05fb 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -113,13 +113,13 @@ func getContextValue(ec *EngineEvaluationContext, property string) ContextValue return nil } -// getContextValueGetter returns a cached function to retrieve a value from a map[string]any -// using either a JSONPath expression or a fallback trait key. +// getContextValueGetter returns a function to retrieve a value from the evaluation context +// using either a JSONPath expression or returning nil if the property is not a valid JSONPath. func getContextValueGetter(property string) func(ec *EngineEvaluationContext) any { // First, try to parse the property as a JSONPath expression. p, err := jp.ParseString(property) if err == nil { - // If successful, create and cache a getter for the JSONPath. + // If successful, create a getter for the JSONPath. getter := func(evalCtx *EngineEvaluationContext) any { results := p.Get(evalCtx) if len(results) > 0 { From 068c7095745ba8b1c0f0aaae92b4d704d855801c Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 1 Oct 2025 15:12:28 +0530 Subject: [PATCH 12/56] use the new schema --- flagengine/engine.go | 9 ++++----- flagengine/engine_eval/result.go | 7 +++---- flagengine/flagengine_integration_test.go | 19 ++++++++----------- models.go | 6 +++--- models_test.go | 22 +++++++++------------- 5 files changed, 27 insertions(+), 36 deletions(-) diff --git a/flagengine/engine.go b/flagengine/engine.go index 6ba093b8..b5cd3ec2 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -130,7 +130,7 @@ func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.Ev const defaultPriority = 0.0 segments := []engine_eval.SegmentResult{} - flags := []engine_eval.FlagResult{} + flags := make(map[string]*engine_eval.FlagResult) segmentFeatureContexts := make(map[string]featureContextWithSegmentName) // Process segments @@ -195,23 +195,22 @@ func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.Ev // Use segment override fc := segmentFeatureCtx.featureContext reason := fmt.Sprintf("TARGETING_MATCH; segment=%s", segmentFeatureCtx.segmentName) - flags = append(flags, engine_eval.FlagResult{ + flags[featureContext.Name] = &engine_eval.FlagResult{ Enabled: fc.Enabled, FeatureKey: fc.FeatureKey, Name: fc.Name, Reason: &reason, Value: fc.Value, - }) + } } else { // Use default feature context flagResult := getFlagResultFromFeatureContext(&featureContext, identityKey) - flags = append(flags, flagResult) + flags[featureContext.Name] = &flagResult } } } return engine_eval.EvaluationResult{ - Context: *ec, Flags: flags, Segments: segments, } diff --git a/flagengine/engine_eval/result.go b/flagengine/engine_eval/result.go index fc1bdf27..fd06a81f 100644 --- a/flagengine/engine_eval/result.go +++ b/flagengine/engine_eval/result.go @@ -1,11 +1,10 @@ package engine_eval -// Evaluation result object containing the used context, flag evaluation results, and +// Evaluation result object containing flag evaluation results, and // segments used in the evaluation. type EvaluationResult struct { - Context EngineEvaluationContext `json:"context"` - // List of feature flags evaluated for the context. - Flags []FlagResult `json:"flags"` + // Feature flags evaluated for the context, mapped by feature names. + Flags map[string]*FlagResult `json:"flags"` // List of segments which the provided context belongs to. Segments []SegmentResult `json:"segments"` } diff --git a/flagengine/flagengine_integration_test.go b/flagengine/flagengine_integration_test.go index 657799cd..5359c42e 100644 --- a/flagengine/flagengine_integration_test.go +++ b/flagengine/flagengine_integration_test.go @@ -3,7 +3,6 @@ package flagengine_test import ( "encoding/json" "os" - "sort" "strconv" "testing" @@ -41,18 +40,16 @@ func TestEngine(t *testing.T) { actual := flagengine.GetEvaluationResult(&c.EvaluationContext) expected := c.EvaluationResult - sort.Slice(actual.Flags, func(i, j int) bool { - return actual.Flags[i].FeatureKey < actual.Flags[j].FeatureKey - }) - sort.Slice(expected.Flags, func(i, j int) bool { - return expected.Flags[i].FeatureKey < expected.Flags[j].FeatureKey - }) + // Note: Flags are now a map, so no need to sort them + // The comparison will be done by comparing the map contents directly require.Len(actual.Flags, len(expected.Flags)) - for i := range expected.Flags { - assert.Equal(expected.Flags[i].Value, actual.Flags[i].Value) - assert.Equal(expected.Flags[i].Enabled, actual.Flags[i].Enabled) - assert.Equal(expected.Flags[i].FeatureKey, actual.Flags[i].FeatureKey) + for featureName, expectedFlag := range expected.Flags { + actualFlag, exists := actual.Flags[featureName] + require.True(exists, "Expected flag %s not found in actual result", featureName) + assert.Equal(expectedFlag.Value, actualFlag.Value) + assert.Equal(expectedFlag.Enabled, actualFlag.Enabled) + assert.Equal(expectedFlag.FeatureKey, actualFlag.FeatureKey) } }) } diff --git a/models.go b/models.go index 5b091116..6fe44482 100644 --- a/models.go +++ b/models.go @@ -69,9 +69,9 @@ type Flags struct { } func makeFlagsFromEngineEvaluationResult(evaluationResult *engine_eval.EvaluationResult, analyticsProcessor *AnalyticsProcessor, defaultFlagHandler func(string) (Flag, error)) Flags { - flags := make([]Flag, len(evaluationResult.Flags)) - for i, flagResult := range evaluationResult.Flags { - flags[i] = makeFlagFromEngineEvaluationFlagResult(&flagResult) + flags := make([]Flag, 0, len(evaluationResult.Flags)) + for _, flagResult := range evaluationResult.Flags { + flags = append(flags, makeFlagFromEngineEvaluationFlagResult(flagResult)) } return Flags{ diff --git a/models_test.go b/models_test.go index f60dd7b3..1bdb177d 100644 --- a/models_test.go +++ b/models_test.go @@ -169,9 +169,8 @@ func TestMakeFlagsFromEngineEvaluationResult(t *testing.T) { { name: "evaluation result with multiple flags", input: &engine_eval.EvaluationResult{ - Context: engine_eval.EngineEvaluationContext{}, - Flags: []engine_eval.FlagResult{ - { + Flags: map[string]*engine_eval.FlagResult{ + "feature1": { Enabled: true, FeatureKey: "feature1_key", Name: "feature1", @@ -179,7 +178,7 @@ func TestMakeFlagsFromEngineEvaluationResult(t *testing.T) { String: stringPtr("value1"), }, }, - { + "feature2": { Enabled: false, FeatureKey: "feature2_key", Name: "feature2", @@ -187,7 +186,7 @@ func TestMakeFlagsFromEngineEvaluationResult(t *testing.T) { Bool: boolPtr(true), }, }, - { + "feature3": { Enabled: true, FeatureKey: "feature3_key", Name: "feature3", @@ -225,8 +224,7 @@ func TestMakeFlagsFromEngineEvaluationResult(t *testing.T) { { name: "evaluation result with no flags", input: &engine_eval.EvaluationResult{ - Context: engine_eval.EngineEvaluationContext{}, - Flags: []engine_eval.FlagResult{}, + Flags: map[string]*engine_eval.FlagResult{}, Segments: []engine_eval.SegmentResult{}, }, expected: []Flag{}, @@ -234,9 +232,8 @@ func TestMakeFlagsFromEngineEvaluationResult(t *testing.T) { { name: "evaluation result with single flag", input: &engine_eval.EvaluationResult{ - Context: engine_eval.EngineEvaluationContext{}, - Flags: []engine_eval.FlagResult{ - { + Flags: map[string]*engine_eval.FlagResult{ + "single_feature": { Enabled: true, FeatureKey: "single_feature_key", Name: "single_feature", @@ -315,9 +312,8 @@ func TestMakeFlagsFromEngineEvaluationResultWithProcessorAndHandler(t *testing.T } input := &engine_eval.EvaluationResult{ - Context: engine_eval.EngineEvaluationContext{}, - Flags: []engine_eval.FlagResult{ - { + Flags: map[string]*engine_eval.FlagResult{ + "test_feature": { Enabled: true, FeatureKey: "test_feature_key", Name: "test_feature", From e214b1430017e52df34b69fc5f36c8c998b52ad9 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 1 Oct 2025 15:18:05 +0530 Subject: [PATCH 13/56] Bump major version --- README.md | 2 +- client.go | 8 ++++---- client_test.go | 4 ++-- flagengine/engine.go | 14 +++++++------- flagengine/engine_eval/evaluator.go | 2 +- flagengine/engine_eval/evaluator_test.go | 4 ++-- flagengine/engine_eval/mappers.go | 8 ++++---- flagengine/engine_eval/mappers_test.go | 12 ++++++------ flagengine/engine_test.go | 10 +++++----- flagengine/environments/models.go | 6 +++--- flagengine/features/models.go | 2 +- flagengine/features/models_test.go | 2 +- flagengine/flagengine_integration_test.go | 6 +++--- flagengine/identities/models.go | 6 +++--- flagengine/projects/models.go | 4 ++-- flagengine/segments/evaluator.go | 4 ++-- flagengine/segments/models.go | 4 ++-- flagengine/utils/fixtures/fixtures.go | 16 ++++++++-------- flagengine/utils/hashing_test.go | 2 +- flagengine/utils/time_test.go | 2 +- go.mod | 5 ++--- go.sum | 2 -- models.go | 4 ++-- models_test.go | 2 +- offline_handler.go | 2 +- offline_handler_test.go | 2 +- realtime.go | 2 +- 27 files changed, 67 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 9a3dc367..97d6481a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Go](https://github.com/flagsmith/flagsmith-go-client/workflows/Go/badge.svg?branch=main)](https://github.com/flagsmith/flagsmith-go-client/actions) [![GoReportCard](https://goreportcard.com/badge/github.com/flagsmith/flagsmith-go-client)](https://goreportcard.com/report/github.com/flagsmith/flagsmith-go-client) -[![GoDoc](https://godoc.org/github.com/flagsmith/flagsmith-go-client/v4?status.svg)](https://pkg.go.dev/github.com/Flagsmith/flagsmith-go-client/v4#section-documentation) +[![GoDoc](https://godoc.org/github.com/flagsmith/flagsmith-go-client/v5?status.svg)](https://pkg.go.dev/github.com/Flagsmith/flagsmith-go-client/v5#section-documentation) # Flagsmith Go SDK diff --git a/client.go b/client.go index 3b8c0be8..760a93f0 100644 --- a/client.go +++ b/client.go @@ -11,10 +11,10 @@ import ( "sync/atomic" "time" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" "github.com/go-resty/resty/v2" ) diff --git a/client_test.go b/client_test.go index bdee4669..a3bcc08a 100644 --- a/client_test.go +++ b/client_test.go @@ -13,8 +13,8 @@ import ( "testing" "time" - flagsmith "github.com/Flagsmith/flagsmith-go-client/v4" - "github.com/Flagsmith/flagsmith-go-client/v4/fixtures" + flagsmith "github.com/Flagsmith/flagsmith-go-client/v5" + "github.com/Flagsmith/flagsmith-go-client/v5/fixtures" "github.com/go-resty/resty/v2" "github.com/stretchr/testify/assert" ) diff --git a/flagengine/engine.go b/flagengine/engine.go index b5cd3ec2..ef14bf61 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -3,13 +3,13 @@ package flagengine import ( "fmt" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities/traits" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) // GetEnvironmentFeatureStates returns a list of feature states for a given environment. diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index 3d6a05fb..8d5ac128 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" "github.com/blang/semver/v4" "github.com/ohler55/ojg/jp" ) diff --git a/flagengine/engine_eval/evaluator_test.go b/flagengine/engine_eval/evaluator_test.go index be294e6b..07c19a4a 100644 --- a/flagengine/engine_eval/evaluator_test.go +++ b/flagengine/engine_eval/evaluator_test.go @@ -6,8 +6,8 @@ import ( "github.com/stretchr/testify/assert" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) const ( diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index cf60e4b6..1d35eaba 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -10,10 +10,10 @@ import ( "strconv" "strings" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" ) // MapEnvironmentDocumentToEvaluationContext maps an environment document model diff --git a/flagengine/engine_eval/mappers_test.go b/flagengine/engine_eval/mappers_test.go index 69c7f853..29b87105 100644 --- a/flagengine/engine_eval/mappers_test.go +++ b/flagengine/engine_eval/mappers_test.go @@ -5,12 +5,12 @@ import ( "testing" "time" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/projects" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/projects" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) func TestMapEnvironmentDocumentToEvaluationContext(t *testing.T) { diff --git a/flagengine/engine_test.go b/flagengine/engine_test.go index 99ab40e6..fd4b0737 100644 --- a/flagengine/engine_test.go +++ b/flagengine/engine_test.go @@ -3,11 +3,11 @@ package flagengine_test import ( "testing" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils/fixtures" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities/traits" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils/fixtures" "github.com/stretchr/testify/assert" ) diff --git a/flagengine/environments/models.go b/flagengine/environments/models.go index 24f30642..bd5be9ac 100644 --- a/flagengine/environments/models.go +++ b/flagengine/environments/models.go @@ -3,9 +3,9 @@ package environments import ( "time" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/projects" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/projects" ) type EnvironmentModel struct { diff --git a/flagengine/features/models.go b/flagengine/features/models.go index d7699b12..631b14d9 100644 --- a/flagengine/features/models.go +++ b/flagengine/features/models.go @@ -5,7 +5,7 @@ import ( "sort" "strconv" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) type FeatureModel struct { diff --git a/flagengine/features/models_test.go b/flagengine/features/models_test.go index 56f250d9..26d787d4 100644 --- a/flagengine/features/models_test.go +++ b/flagengine/features/models_test.go @@ -3,7 +3,7 @@ package features_test import ( "testing" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" "github.com/stretchr/testify/assert" ) diff --git a/flagengine/flagengine_integration_test.go b/flagengine/flagengine_integration_test.go index 5359c42e..643b0e9c 100644 --- a/flagengine/flagengine_integration_test.go +++ b/flagengine/flagengine_integration_test.go @@ -9,9 +9,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" ) const TestData = "./engine-test-data/data/environment_n9fbf9h3v4fFgH3U3ngWhb.json" diff --git a/flagengine/identities/models.go b/flagengine/identities/models.go index 96e16ba2..7cbc1d49 100644 --- a/flagengine/identities/models.go +++ b/flagengine/identities/models.go @@ -1,9 +1,9 @@ package identities import ( - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities/traits" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) type IdentityModel struct { diff --git a/flagengine/projects/models.go b/flagengine/projects/models.go index 57acd461..303385c0 100644 --- a/flagengine/projects/models.go +++ b/flagengine/projects/models.go @@ -1,8 +1,8 @@ package projects import ( - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/organisations" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/organisations" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" ) type ProjectModel struct { diff --git a/flagengine/segments/evaluator.go b/flagengine/segments/evaluator.go index 3a2dbca3..3c7fa61f 100644 --- a/flagengine/segments/evaluator.go +++ b/flagengine/segments/evaluator.go @@ -1,8 +1,8 @@ package segments import ( - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities/traits" ) func EvaluateIdentityInSegment( diff --git a/flagengine/segments/models.go b/flagengine/segments/models.go index f2b842de..cb4ab138 100644 --- a/flagengine/segments/models.go +++ b/flagengine/segments/models.go @@ -1,8 +1,8 @@ package segments import ( - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) type SegmentConditionModel struct { diff --git a/flagengine/utils/fixtures/fixtures.go b/flagengine/utils/fixtures/fixtures.go index 75bfca72..6c529282 100644 --- a/flagengine/utils/fixtures/fixtures.go +++ b/flagengine/utils/fixtures/fixtures.go @@ -3,14 +3,14 @@ package fixtures import ( "time" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/organisations" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/projects" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/segments" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities/traits" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/organisations" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/projects" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) const ( diff --git a/flagengine/utils/hashing_test.go b/flagengine/utils/hashing_test.go index adf62ee0..b4ccc916 100644 --- a/flagengine/utils/hashing_test.go +++ b/flagengine/utils/hashing_test.go @@ -9,7 +9,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) func TestGetHashedPercentageForObjectIds(t *testing.T) { diff --git a/flagengine/utils/time_test.go b/flagengine/utils/time_test.go index 3c2f00e8..0fdc743e 100644 --- a/flagengine/utils/time_test.go +++ b/flagengine/utils/time_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/utils" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) func TestUnmarshal(t *testing.T) { diff --git a/go.mod b/go.mod index 4044cea6..cb643ab7 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,11 @@ -module github.com/Flagsmith/flagsmith-go-client/v4 +module github.com/Flagsmith/flagsmith-go-client/v5 -go 1.22 +go 1.25 require ( github.com/blang/semver/v4 v4.0.0 github.com/google/uuid v1.6.0 github.com/stretchr/testify v1.10.0 - golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 ) require ( diff --git a/go.sum b/go.sum index 59bcdefe..c3582f2c 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= diff --git a/models.go b/models.go index 6fe44482..59f5a163 100644 --- a/models.go +++ b/models.go @@ -5,8 +5,8 @@ import ( "fmt" "strconv" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/identities/traits" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities/traits" ) type Flag struct { diff --git a/models_test.go b/models_test.go index 1bdb177d..9daf6054 100644 --- a/models_test.go +++ b/models_test.go @@ -3,7 +3,7 @@ package flagsmith import ( "testing" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/engine_eval" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" ) func TestMakeFlagFromEngineEvaluationFlagResult(t *testing.T) { diff --git a/offline_handler.go b/offline_handler.go index 3a69d19c..6f6235b2 100644 --- a/offline_handler.go +++ b/offline_handler.go @@ -4,7 +4,7 @@ import ( "encoding/json" "os" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" ) type OfflineHandler interface { diff --git a/offline_handler_test.go b/offline_handler_test.go index 1175ef07..5e695d99 100644 --- a/offline_handler_test.go +++ b/offline_handler_test.go @@ -3,7 +3,7 @@ package flagsmith_test import ( "testing" - flagsmith "github.com/Flagsmith/flagsmith-go-client/v4" + flagsmith "github.com/Flagsmith/flagsmith-go-client/v5" "github.com/stretchr/testify/assert" ) diff --git a/realtime.go b/realtime.go index 8bc74559..a0c77d8e 100644 --- a/realtime.go +++ b/realtime.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/Flagsmith/flagsmith-go-client/v4/flagengine/environments" + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" ) // realtime handles the SSE connection and reconnection logic. From 4da51db7965bdf5c9a4aa052adb4acd497b00e68 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 1 Oct 2025 15:53:46 +0530 Subject: [PATCH 14/56] Add missing operators --- flagengine/engine_eval/evaluator.go | 44 +++++++ flagengine/engine_eval/evaluator_test.go | 139 +++++++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index 8d5ac128..0e825307 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -2,6 +2,8 @@ package engine_eval import ( "fmt" + "math" + "regexp" "slices" "strconv" "strings" @@ -156,6 +158,14 @@ func ToString(contextValue ContextValue) string { } func match(c Operator, traitValue, conditionValue string) bool { + // Handle special operators first + switch c { + case Modulo: + return matchModulo(traitValue, conditionValue) + case Regex: + return matchRegex(traitValue, conditionValue) + } + b1, e1 := strconv.ParseBool(traitValue) b2, e2 := strconv.ParseBool(conditionValue) if e1 == nil && e2 == nil { @@ -275,3 +285,37 @@ func matchString(c Operator, v1, v2 string) bool { } return v1 == v2 } + +// matchRegex performs regex matching on trait values. +func matchRegex(traitValue, conditionValue string) bool { + match, err := regexp.Match(conditionValue, []byte(traitValue)) + if err != nil { + return false + } + return match +} + +// matchModulo performs modulo operation matching on trait values. +func matchModulo(traitValue, conditionValue string) bool { + values := strings.Split(conditionValue, "|") + if len(values) != 2 { + return false + } + + divisor, err := strconv.ParseFloat(values[0], 64) + if err != nil { + return false + } + + remainder, err := strconv.ParseFloat(values[1], 64) + if err != nil { + return false + } + + traitValueFloat, err := strconv.ParseFloat(traitValue, 64) + if err != nil { + return false + } + + return math.Mod(traitValueFloat, divisor) == remainder +} diff --git a/flagengine/engine_eval/evaluator_test.go b/flagengine/engine_eval/evaluator_test.go index 07c19a4a..fc69f148 100644 --- a/flagengine/engine_eval/evaluator_test.go +++ b/flagengine/engine_eval/evaluator_test.go @@ -808,3 +808,142 @@ func TestEdgeCases(t *testing.T) { assert.False(t, result) // Should fail without identity }) } + +func TestRegexOperator(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + traitValue string + conditionValue string + expected bool + }{ + {"simple match", "foo", "[a-z]+", true}, + {"no match", "FOO", "[a-z]+", false}, + {"email match", "test@example.com", `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`, true}, + {"invalid regex", "test", "[", false}, + {"empty values", "", "", true}, + {"number match", "123", `^\d+$`, true}, + {"number no match", "abc", `^\d+$`, false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + "test_trait": stringValue(c.traitValue), + }) + + segmentContext := createSegmentContext("regex_test", "regex_test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Regex, + Property: "test_trait", + Value: &engine_eval.ValueUnion{String: stringPtr(c.conditionValue)}, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.Equal(t, c.expected, result) + }) + } +} + +func TestModuloOperator(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + traitValue string + conditionValue string + expected bool + }{ + {"simple modulo match", "2", "2|0", true}, + {"simple modulo no match", "1", "2|0", false}, + {"float modulo match", "1.1", "2.1|1.1", true}, + {"float modulo no match", "3", "2|0", false}, + {"large number match", "35.0", "4|3", true}, + {"large number no match", "34.2", "4|3", false}, + {"invalid trait value", "foo", "4|3", false}, + {"invalid condition format", "1", "invalid", false}, + {"invalid divisor", "1", "abc|3", false}, + {"invalid remainder", "1", "4|abc", false}, + {"missing separator", "1", "43", false}, + {"too many parts", "1", "4|3|2", false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + "test_trait": stringValue(c.traitValue), + }) + + segmentContext := createSegmentContext("modulo_test", "modulo_test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Modulo, + Property: "test_trait", + Value: &engine_eval.ValueUnion{String: stringPtr(c.conditionValue)}, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.Equal(t, c.expected, result) + }) + } +} + +func TestMatchWithRegexOperator(t *testing.T) { + t.Parallel() + + evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + "email": stringValue("test@example.com"), + }) + + segmentContext := createSegmentContext("regex_test", "regex_test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Regex, + Property: "email", + Value: &engine_eval.ValueUnion{String: stringPtr(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)}, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) +} + +func TestMatchWithModuloOperator(t *testing.T) { + t.Parallel() + + evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + "user_id": stringValue("35"), + }) + + segmentContext := createSegmentContext("modulo_test", "modulo_test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{ + { + Operator: engine_eval.Modulo, + Property: "user_id", + Value: &engine_eval.ValueUnion{String: stringPtr("4|3")}, + }, + }, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.True(t, result) +} From 41105e6740320f82b652c1c05cb4fdabfa8ce052 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 2 Oct 2025 08:30:50 +0530 Subject: [PATCH 15/56] remove old interface --- flagengine/engine.go | 111 -------------------- flagengine/engine_test.go | 169 ------------------------------- flagengine/segments/evaluator.go | 14 --- 3 files changed, 294 deletions(-) delete mode 100644 flagengine/engine_test.go delete mode 100644 flagengine/segments/evaluator.go diff --git a/flagengine/engine.go b/flagengine/engine.go index ef14bf61..18c6ec6a 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -4,120 +4,9 @@ import ( "fmt" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities/traits" - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) -// GetEnvironmentFeatureStates returns a list of feature states for a given environment. -func GetEnvironmentFeatureStates(environment *environments.EnvironmentModel) []*features.FeatureStateModel { - if environment.Project.HideDisabledFlags { - var featureStates []*features.FeatureStateModel - for _, fs := range environment.FeatureStates { - if fs.Enabled { - featureStates = append(featureStates, fs) - } - } - return featureStates - } - return environment.FeatureStates -} - -// GetEnvironmentFeatureState returns a specific feature state for a given featureName in a given environment, or nil feature state is not found. -func GetEnvironmentFeatureState(environment *environments.EnvironmentModel, featureName string) *features.FeatureStateModel { - for _, fs := range environment.FeatureStates { - if fs.Feature.Name == featureName { - return fs - } - } - return nil -} - -// GetIdentityFeatureStates returns a list of feature states for a given identity in a given environment. -func GetIdentityFeatureStates( - environment *environments.EnvironmentModel, - identity *identities.IdentityModel, - overrideTraits ...*traits.TraitModel, -) []*features.FeatureStateModel { - featureStatesMap := getIdentityFeatureStatesMap(environment, identity, overrideTraits...) - featureStates := make([]*features.FeatureStateModel, 0, len(featureStatesMap)) - hideDisabled := environment.Project.HideDisabledFlags - for _, fs := range featureStatesMap { - if hideDisabled && !fs.Enabled { - continue - } - featureStates = append(featureStates, fs) - } - - return featureStates -} - -func GetIdentityFeatureState( - environment *environments.EnvironmentModel, - identity *identities.IdentityModel, - featureName string, - overrideTraits ...*traits.TraitModel, -) *features.FeatureStateModel { - featureStates := getIdentityFeatureStatesMap(environment, identity, overrideTraits...) - - for _, featureState := range featureStates { - if featureState.Feature.Name == featureName { - return featureState - } - } - return nil -} - -func GetIdentitySegments( - environment *environments.EnvironmentModel, - identity *identities.IdentityModel, - overrideTraits ...*traits.TraitModel, -) []*segments.SegmentModel { - var list []*segments.SegmentModel - - for _, s := range environment.Project.Segments { - if segments.EvaluateIdentityInSegment(identity, s, overrideTraits...) { - list = append(list, s) - } - } - - return list -} - -func getIdentityFeatureStatesMap( - environment *environments.EnvironmentModel, - identity *identities.IdentityModel, - overrideTraits ...*traits.TraitModel, -) map[int]*features.FeatureStateModel { - featureStates := make(map[int]*features.FeatureStateModel) - for _, fs := range environment.FeatureStates { - featureStates[fs.Feature.ID] = fs - } - - identitySegments := GetIdentitySegments(environment, identity, overrideTraits...) - for _, segment := range identitySegments { - for _, fs := range segment.FeatureStates { - existing_fs, exists := featureStates[fs.Feature.ID] - if exists && existing_fs.IsHigherSegmentPriority(fs) { - continue - } - - featureStates[fs.Feature.ID] = fs - } - } - - for _, fs := range identity.IdentityFeatures { - if _, ok := featureStates[fs.Feature.ID]; ok { - featureStates[fs.Feature.ID] = fs - } - } - - return featureStates -} - // featureContextWithSegmentName holds a feature context along with the segment name it came from. type featureContextWithSegmentName struct { featureContext *engine_eval.FeatureContext diff --git a/flagengine/engine_test.go b/flagengine/engine_test.go deleted file mode 100644 index fd4b0737..00000000 --- a/flagengine/engine_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package flagengine_test - -import ( - "testing" - - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine" - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities/traits" - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils/fixtures" - "github.com/stretchr/testify/assert" -) - -func TestIdentityGetFeatureStateWithoutAnyOverride(t *testing.T) { - t.Parallel() - feature1, _, _, env, identity := fixtures.GetFixtures() - - featureState := flagengine.GetIdentityFeatureState(env, identity, feature1.Name) - assert.Equal(t, feature1, featureState.Feature) -} - -func TestIdentityGetAllFeatureStatesNoSegments(t *testing.T) { - t.Parallel() - _, _, _, env, identity := fixtures.GetFixtures() - - overriddenFeature := &features.FeatureModel{ID: 3, Name: "overridden_feature", Type: "STANDARD"} - - // set the state of the feature to false in the environment configuration - env.FeatureStates = append(env.FeatureStates, &features.FeatureStateModel{ - DjangoID: 3, Feature: overriddenFeature, Enabled: false, - }) - - // but true for the identity - identity.IdentityFeatures = []*features.FeatureStateModel{ - {DjangoID: 4, Feature: overriddenFeature, Enabled: true}, - } - - allFeatureStates := flagengine.GetIdentityFeatureStates(env, identity) - assert.Len(t, allFeatureStates, 3) - for _, fs := range allFeatureStates { - envFeatureState := getEnvironmentFeatureStateForFeature(env, fs.Feature) - - var expected bool - if fs.Feature == overriddenFeature { - expected = true - } else { - expected = envFeatureState.Enabled - } - assert.Equal(t, expected, fs.Enabled) - } -} - -func TestGetIdentityFeatureStatesHidesDisabledFlagsIfEnabled(t *testing.T) { - t.Parallel() - _, _, _, env, identity := fixtures.GetFixtures() - env.Project.HideDisabledFlags = true - - featureStates := flagengine.GetIdentityFeatureStates(env, identity) - - for _, fs := range featureStates { - assert.True(t, fs.Enabled) - } -} - -func TestIdentityGetAllFeatureStatesSegmentsOnly(t *testing.T) { - t.Parallel() - _, _, segment, env, _ := fixtures.GetFixtures() - traitMatchingSegment := fixtures.TraitMatchingSegment(fixtures.SegmentCondition()) - identityInSegment := fixtures.IdentityInSegment(traitMatchingSegment, env) - - overriddenFeature := &features.FeatureModel{ - ID: 3, - Name: "overridden_feature", - Type: "STANDARD", - } - - env.FeatureStates = append(env.FeatureStates, &features.FeatureStateModel{ - DjangoID: 3, - Feature: overriddenFeature, - Enabled: false, - }) - - segment.FeatureStates = append(segment.FeatureStates, &features.FeatureStateModel{ - DjangoID: 4, - Feature: overriddenFeature, - Enabled: true, - }) - - allFeatureStates := flagengine.GetIdentityFeatureStates(env, identityInSegment) - - assert.Len(t, allFeatureStates, 3) - - for _, fs := range allFeatureStates { - envFeatureState := getEnvironmentFeatureStateForFeature(env, fs.Feature) - expected := envFeatureState.Enabled - if fs.Feature == overriddenFeature { - expected = true - } - assert.Equal(t, expected, fs.Enabled) - } -} - -func TestIdentityGetAllFeatureStatesWithTraits(t *testing.T) { - feature1, _, segment, env, identity := fixtures.GetFixtures() - - envWithSegmentOverride := fixtures.EnvironmentWithSegmentOverride(env, fixtures.SegmentOverrideFs(segment, feature1), segment) - - traitModels := []*traits.TraitModel{ - {TraitKey: fixtures.SegmentConditionProperty, TraitValue: fixtures.SegmentConditionStringValue}, - } - - allFeatureStates := flagengine.GetIdentityFeatureStates(envWithSegmentOverride, identity, traitModels...) - found := false - for _, fs := range allFeatureStates { - if fs.RawValue == "segment_override" { - found = true - break - } - } - assert.True(t, found, "expected to find feature state with segment_override value") -} - -func TestEnvironmentGetAllFeatureStates(t *testing.T) { - t.Parallel() - - _, _, _, env, _ := fixtures.GetFixtures() - featureStates := flagengine.GetEnvironmentFeatureStates(env) - - assert.Equal(t, env.FeatureStates, featureStates) -} - -func TestEnvironmentGetFeatureStatesHidesDisabledFlagsIfEnabled(t *testing.T) { - t.Parallel() - - _, _, _, env, _ := fixtures.GetFixtures() - env.Project.HideDisabledFlags = true - featureStates := flagengine.GetEnvironmentFeatureStates(env) - - assert.NotEqual(t, env.FeatureStates, featureStates) - for _, fs := range featureStates { - assert.True(t, fs.Enabled) - } -} - -func TestEnvironmentGetFeatureState(t *testing.T) { - t.Parallel() - - feature1, _, _, env, _ := fixtures.GetFixtures() - fs := flagengine.GetEnvironmentFeatureState(env, feature1.Name) - - assert.Equal(t, feature1, fs.Feature) -} - -func TestEnvironmentGetFeatureStateFeatureNotFound(t *testing.T) { - t.Parallel() - - _, _, _, env, _ := fixtures.GetFixtures() - fs := flagengine.GetEnvironmentFeatureState(env, "not_a_feature_name") - assert.Nil(t, fs) -} - -func getEnvironmentFeatureStateForFeature(env *environments.EnvironmentModel, feature *features.FeatureModel) *features.FeatureStateModel { - for _, fs := range env.FeatureStates { - if fs.Feature == feature { - return fs - } - } - return nil -} diff --git a/flagengine/segments/evaluator.go b/flagengine/segments/evaluator.go deleted file mode 100644 index 3c7fa61f..00000000 --- a/flagengine/segments/evaluator.go +++ /dev/null @@ -1,14 +0,0 @@ -package segments - -import ( - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities/traits" -) - -func EvaluateIdentityInSegment( - identity *identities.IdentityModel, - segment *SegmentModel, - overrideTraits ...*traits.TraitModel, -) bool { - return true -} From 86dc538f7b74f4c0264aae620a81179022807e82 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 2 Oct 2025 08:38:19 +0530 Subject: [PATCH 16/56] update engine test data --- flagengine/engine-test-data | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flagengine/engine-test-data b/flagengine/engine-test-data index 18c68ef9..facf33a4 160000 --- a/flagengine/engine-test-data +++ b/flagengine/engine-test-data @@ -1 +1 @@ -Subproject commit 18c68ef925910622a228af2892aed48b21e532fe +Subproject commit facf33a4c50fdabdce29899b19b9ea65ea70eb18 From 192883f11156c9fb0c8cb3efb6ba7d237f1e5e49 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 2 Oct 2025 09:53:29 +0530 Subject: [PATCH 17/56] remove go get --- .github/workflows/go.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 10e41796..f87d506b 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -29,10 +29,6 @@ jobs: with: submodules: recursive - - name: Get dependencies - run: | - go get -v -t -d ./... - - name: Lint uses: golangci/golangci-lint-action@v6 From c778096b23fa3cdcaad96a6c179989d04d23a60c Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 2 Oct 2025 09:58:31 +0530 Subject: [PATCH 18/56] use go 1.24 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index cb643ab7..268f02a5 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Flagsmith/flagsmith-go-client/v5 -go 1.25 +go 1.24 require ( github.com/blang/semver/v4 v4.0.0 From f85071ae264eed3b3a9cfdbdfbb18543e1c8b72f Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 2 Oct 2025 15:35:02 +0530 Subject: [PATCH 19/56] refactor: create a function for processing segments --- flagengine/engine.go | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/flagengine/engine.go b/flagengine/engine.go index 18c6ec6a..84d919aa 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -2,6 +2,7 @@ package flagengine import ( "fmt" + "math" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" @@ -13,13 +14,12 @@ type featureContextWithSegmentName struct { segmentName string } -// GetEvaluationResult computes flags and matched segments given a context and a segment matcher. -// The matcher should return true when the provided segment applies to the provided context. -func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.EvaluationResult { - const defaultPriority = 0.0 +// processSegments processes all segments in the evaluation context and returns matched segments +// and segment feature contexts for overrides. +func processSegments(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.SegmentResult, map[string]featureContextWithSegmentName) { + var defaultPriority = math.Inf(1) segments := []engine_eval.SegmentResult{} - flags := make(map[string]*engine_eval.FlagResult) segmentFeatureContexts := make(map[string]featureContextWithSegmentName) // Process segments @@ -40,7 +40,6 @@ func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.Ev override := &segmentContext.Overrides[i] featureKey := override.FeatureKey - // Get priority, defaulting to 0 if not set overridePriority := defaultPriority if override.Priority != nil { overridePriority = *override.Priority @@ -70,6 +69,17 @@ func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.Ev } } + return segments, segmentFeatureContexts +} + +// GetEvaluationResult computes flags and matched segments given a context and a segment matcher. +// The matcher should return true when the provided segment applies to the provided context. +func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.EvaluationResult { + flags := make(map[string]*engine_eval.FlagResult) + + // Process segments + segments, segmentFeatureContexts := processSegments(ec) + // Get identity key if identity exists var identityKey *string if ec.Identity != nil { From a52a3015580ec746c061e5706a6b2007ad4d6642 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 2 Oct 2025 15:40:42 +0530 Subject: [PATCH 20/56] refac: create a diff function for processing features --- flagengine/engine.go | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/flagengine/engine.go b/flagengine/engine.go index 84d919aa..644c1d1d 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -14,8 +14,7 @@ type featureContextWithSegmentName struct { segmentName string } -// processSegments processes all segments in the evaluation context and returns matched segments -// and segment feature contexts for overrides. +// 2. A map of feature overrides from matching segments, with priority-based selection. func processSegments(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.SegmentResult, map[string]featureContextWithSegmentName) { var defaultPriority = math.Inf(1) @@ -72,14 +71,10 @@ func processSegments(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.Seg return segments, segmentFeatureContexts } -// GetEvaluationResult computes flags and matched segments given a context and a segment matcher. -// The matcher should return true when the provided segment applies to the provided context. -func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.EvaluationResult { +// processFeatures processes all features in the evaluation context and returns flag results. +func processFeatures(ec *engine_eval.EngineEvaluationContext, segmentFeatureContexts map[string]featureContextWithSegmentName) map[string]*engine_eval.FlagResult { flags := make(map[string]*engine_eval.FlagResult) - // Process segments - segments, segmentFeatureContexts := processSegments(ec) - // Get identity key if identity exists var identityKey *string if ec.Identity != nil { @@ -109,6 +104,17 @@ func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.Ev } } + return flags +} + +// GetEvaluationResult computes flags and matched segments. +func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.EvaluationResult { + // Process segments + segments, segmentFeatureContexts := processSegments(ec) + + // Process features + flags := processFeatures(ec, segmentFeatureContexts) + return engine_eval.EvaluationResult{ Flags: flags, Segments: segments, From 59d405cff9db115101648513fca7d72f6311bc42 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 2 Oct 2025 15:43:49 +0530 Subject: [PATCH 21/56] refac: squash --- flagengine/engine.go | 1 - 1 file changed, 1 deletion(-) diff --git a/flagengine/engine.go b/flagengine/engine.go index 644c1d1d..61392b23 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -71,7 +71,6 @@ func processSegments(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.Seg return segments, segmentFeatureContexts } -// processFeatures processes all features in the evaluation context and returns flag results. func processFeatures(ec *engine_eval.EngineEvaluationContext, segmentFeatureContexts map[string]featureContextWithSegmentName) map[string]*engine_eval.FlagResult { flags := make(map[string]*engine_eval.FlagResult) From a91e5ec129e432ade2eef054f3281b56368f5683 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 2 Oct 2025 16:04:04 +0530 Subject: [PATCH 22/56] Don't use pointer for feature context --- flagengine/engine.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flagengine/engine.go b/flagengine/engine.go index 61392b23..36409c4d 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -10,7 +10,7 @@ import ( // featureContextWithSegmentName holds a feature context along with the segment name it came from. type featureContextWithSegmentName struct { - featureContext *engine_eval.FeatureContext + featureContext engine_eval.FeatureContext segmentName string } @@ -60,7 +60,7 @@ func processSegments(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.Seg if shouldUpdate { segmentFeatureContexts[featureKey] = featureContextWithSegmentName{ - featureContext: override, + featureContext: *override, segmentName: segmentContext.Name, } } From 0790608893d04fe561d96d6260314c82110c9efd Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 2 Oct 2025 16:08:35 +0530 Subject: [PATCH 23/56] refac default priority --- flagengine/engine.go | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/flagengine/engine.go b/flagengine/engine.go index 36409c4d..ea72c982 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -10,14 +10,19 @@ import ( // featureContextWithSegmentName holds a feature context along with the segment name it came from. type featureContextWithSegmentName struct { - featureContext engine_eval.FeatureContext + featureContext *engine_eval.FeatureContext segmentName string } -// 2. A map of feature overrides from matching segments, with priority-based selection. -func processSegments(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.SegmentResult, map[string]featureContextWithSegmentName) { - var defaultPriority = math.Inf(1) +// getPriorityOrDefault returns the priority value if it exists, otherwise returns the default priority. +func getPriorityOrDefault(priority *float64) float64 { + if priority != nil { + return *priority + } + return math.Inf(1) +} +func processSegments(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.SegmentResult, map[string]featureContextWithSegmentName) { segments := []engine_eval.SegmentResult{} segmentFeatureContexts := make(map[string]featureContextWithSegmentName) @@ -39,20 +44,14 @@ func processSegments(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.Seg override := &segmentContext.Overrides[i] featureKey := override.FeatureKey - overridePriority := defaultPriority - if override.Priority != nil { - overridePriority = *override.Priority - } + overridePriority := getPriorityOrDefault(override.Priority) // Check if we should update the segment feature context shouldUpdate := false if existing, exists := segmentFeatureContexts[featureKey]; !exists { shouldUpdate = true } else { - existingPriority := defaultPriority - if existing.featureContext.Priority != nil { - existingPriority = *existing.featureContext.Priority - } + existingPriority := getPriorityOrDefault(existing.featureContext.Priority) if overridePriority < existingPriority { shouldUpdate = true } @@ -60,7 +59,7 @@ func processSegments(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.Seg if shouldUpdate { segmentFeatureContexts[featureKey] = featureContextWithSegmentName{ - featureContext: *override, + featureContext: override, segmentName: segmentContext.Name, } } From 5f5953d22deb3d4c8764c22fae6dd60beabe4a28 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Fri, 3 Oct 2025 13:17:09 +0530 Subject: [PATCH 24/56] refac splitoperator --- flagengine/engine_eval/evaluator.go | 65 ++++++++++++++++------------- flagengine/engine_eval/mappers.go | 8 +--- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index 0e825307..8ddc939a 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -38,8 +38,10 @@ func contextMatchesSegmentRule(ec *EngineEvaluationContext, segmentRule *Segment matchesConditions = utils.All(conditions) case Any: matchesConditions = utils.Any(conditions) - default: + case None: matchesConditions = utils.None(conditions) + default: + return false } } @@ -55,39 +57,46 @@ func contextMatchesSegmentRule(ec *EngineEvaluationContext, segmentRule *Segment return true } +// matchPercentageSplit handles the PercentageSplit operator for segment conditions. +func matchPercentageSplit(ec *EngineEvaluationContext, segmentCondition *Condition, segmentKey string, contextValue ContextValue) bool { + var objectIds []string + + if contextValue != nil { + // Try to get string representation of the context value + var strValue string + switch v := contextValue.(type) { + case string: + strValue = v + case *Value: + if v != nil && v.String != nil { + strValue = *v.String + } else { + return false + } + default: + return false + } + objectIds = []string{segmentKey, strValue} + } else if ec.Identity != nil { + objectIds = []string{segmentKey, ec.Identity.Key} + } else { + return false + } + + if segmentCondition.Value != nil && segmentCondition.Value.String != nil { + floatValue, _ := strconv.ParseFloat(*segmentCondition.Value.String, 64) + return utils.GetHashedPercentageForObjectIds(objectIds, 1) <= floatValue + } + return false +} + func contextMatchesCondition(ec *EngineEvaluationContext, segmentCondition *Condition, segmentKey string) bool { var contextValue ContextValue if segmentCondition.Property != "" { contextValue = getContextValue(ec, segmentCondition.Property) } if segmentCondition.Operator == PercentageSplit { - var objectIds []string - if contextValue != nil { - // Try to get string representation of the context value - var strValue string - switch v := contextValue.(type) { - case string: - strValue = v - case *Value: - if v != nil && v.String != nil { - strValue = *v.String - } else { - return false - } - default: - return false - } - objectIds = []string{segmentKey, strValue} - } else if ec.Identity != nil { - objectIds = []string{segmentKey, ec.Identity.Key} - } else { - return false - } - if segmentCondition.Value != nil && segmentCondition.Value.String != nil { - floatValue, _ := strconv.ParseFloat(*segmentCondition.Value.String, 64) - return utils.GetHashedPercentageForObjectIds(objectIds, 1) <= floatValue - } - return false + return matchPercentageSplit(ec, segmentCondition, segmentKey, contextValue) } if segmentCondition.Operator == IsNotSet { return contextValue == nil diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index 1d35eaba..c4ecf125 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -336,13 +336,7 @@ func MapContextAndIdentityDataToContext( } // Create the identity context - var environmentKey string - if newContext.Environment.Key != "" { - environmentKey = newContext.Environment.Key - } else { - environmentKey = newContext.Environment.Name - } - + environmentKey := newContext.Environment.Key identity := IdentityContext{ Identifier: identifier, Key: fmt.Sprintf("%s_%s", environmentKey, identifier), From 1899cf89c4ae6790694405669c86694389b6ea83 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Fri, 3 Oct 2025 13:29:02 +0530 Subject: [PATCH 25/56] use stringArry for in operator --- flagengine/engine_eval/evaluator.go | 27 ++++++++++++-- flagengine/engine_eval/evaluator_test.go | 45 ++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index 8ddc939a..20293fea 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -98,6 +98,9 @@ func contextMatchesCondition(ec *EngineEvaluationContext, segmentCondition *Cond if segmentCondition.Operator == PercentageSplit { return matchPercentageSplit(ec, segmentCondition, segmentKey, contextValue) } + if segmentCondition.Operator == In { + return matchInOperator(segmentCondition, contextValue) + } if segmentCondition.Operator == IsNotSet { return contextValue == nil } @@ -110,6 +113,28 @@ func contextMatchesCondition(ec *EngineEvaluationContext, segmentCondition *Cond return false } +// matchInOperator handles the IN operator for segment conditions, supporting both StringArray and comma-separated strings. +func matchInOperator(segmentCondition *Condition, contextValue ContextValue) bool { + if contextValue == nil { + return false + } + + traitValue := ToString(contextValue) + + // First try to use StringArray if available + if segmentCondition.Value != nil && len(segmentCondition.Value.StringArray) > 0 { + return slices.Contains(segmentCondition.Value.StringArray, traitValue) + } + + // Fall back to comma-separated string approach + if segmentCondition.Value != nil && segmentCondition.Value.String != nil { + values := strings.Split(*segmentCondition.Value.String, ",") + return slices.Contains(values, traitValue) + } + + return false +} + func getContextValue(ec *EngineEvaluationContext, property string) ContextValue { if strings.HasPrefix(property, "$.") { return getContextValueGetter(property)(ec) @@ -277,8 +302,6 @@ func matchString(c Operator, v1, v2 string) bool { return strings.Contains(v1, v2) case NotContains: return !strings.Contains(v1, v2) - case In: - return slices.Contains(strings.Split(v2, ","), v1) case Equal: return v1 == v2 case GreaterThan: diff --git a/flagengine/engine_eval/evaluator_test.go b/flagengine/engine_eval/evaluator_test.go index fc69f148..179ce954 100644 --- a/flagengine/engine_eval/evaluator_test.go +++ b/flagengine/engine_eval/evaluator_test.go @@ -329,6 +329,51 @@ func TestContextMatchesCondition(t *testing.T) { } } +func TestContextMatchesConditionInOperatorStringArray(t *testing.T) { + traitKey1 := "trait1" + + cases := []struct { + name string + stringArray []string + traitValue string + expected bool + }{ + {"in string array first", []string{"a", "b", "c"}, "a", true}, + {"in string array middle", []string{"a", "b", "c"}, "b", true}, + {"in string array last", []string{"a", "b", "c"}, "c", true}, + {"not in string array", []string{"a", "b", "c"}, "d", false}, + {"in single item array", []string{"test"}, "test", true}, + {"empty string array", []string{}, "test", false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + condition := &engine_eval.Condition{ + Operator: engine_eval.In, + Property: traitKey1, + Value: &engine_eval.ValueUnion{StringArray: c.stringArray}, + } + + traitValuePtr := stringValue(c.traitValue) + + evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + traitKey1: traitValuePtr, + }) + + // Test via IsContextInSegment + segmentContext := createSegmentContext("test", "test", []engine_eval.SegmentRule{ + { + Type: engine_eval.All, + Conditions: []engine_eval.Condition{*condition}, + }, + }) + + result := engine_eval.IsContextInSegment(evalContext, segmentContext) + assert.Equal(t, c.expected, result) + }) + } +} + func TestContextMatchesConditionIsSetAndIsNotSet(t *testing.T) { t.Parallel() From 379dbf89bb496c759014633257f949a61736a2c7 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Fri, 3 Oct 2025 13:44:20 +0530 Subject: [PATCH 26/56] more refac and add reason to mv --- flagengine/engine.go | 1 + flagengine/engine_eval/evaluator.go | 10 ++++------ flagengine/engine_eval/mappers_test.go | 16 ---------------- flagengine/flagengine_integration_test.go | 13 +------------ 4 files changed, 6 insertions(+), 34 deletions(-) diff --git a/flagengine/engine.go b/flagengine/engine.go index ea72c982..d794520f 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -136,6 +136,7 @@ func getFlagResultFromFeatureContext(featureContext *engine_eval.FeatureContext, cumulativeWeight += variant.Weight if hashPercentage <= cumulativeWeight { value = variant.Value + reason = fmt.Sprintf("SPLIT; weight=%.0f", variant.Weight) break } } diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index 20293fea..66d7288d 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -138,12 +138,10 @@ func matchInOperator(segmentCondition *Condition, contextValue ContextValue) boo func getContextValue(ec *EngineEvaluationContext, property string) ContextValue { if strings.HasPrefix(property, "$.") { return getContextValueGetter(property)(ec) - } else if ec.Identity != nil { - if ec.Identity.Traits != nil { - value, exists := ec.Identity.Traits[property] - if exists { - return value - } + } else if ec.Identity != nil && ec.Identity.Traits != nil { + value, exists := ec.Identity.Traits[property] + if exists { + return value } } return nil diff --git a/flagengine/engine_eval/mappers_test.go b/flagengine/engine_eval/mappers_test.go index 29b87105..4a01992d 100644 --- a/flagengine/engine_eval/mappers_test.go +++ b/flagengine/engine_eval/mappers_test.go @@ -512,22 +512,6 @@ func TestMapContextAndIdentityDataToContextWithNilTraits(t *testing.T) { } } -func TestMapContextAndIdentityDataToContextWithEmptyEnvironmentKey(t *testing.T) { - baseContext := EngineEvaluationContext{ - Environment: EnvironmentContext{ - Key: "", // Empty key - Name: "Test Environment", - }, - } - - result := MapContextAndIdentityDataToContext(baseContext, "test-user", nil) - - // Should use environment name when key is empty - if result.Identity.Key != "Test Environment_test-user" { - t.Errorf("Expected key to use environment name when key is empty, got %v", result.Identity.Key) - } -} - func TestMapEvaluationResultSegmentsToSegmentModels(t *testing.T) { // Create a test evaluation result with segments result := EvaluationResult{ diff --git a/flagengine/flagengine_integration_test.go b/flagengine/flagengine_integration_test.go index 643b0e9c..b1418e1c 100644 --- a/flagengine/flagengine_integration_test.go +++ b/flagengine/flagengine_integration_test.go @@ -36,21 +36,10 @@ func TestEngine(t *testing.T) { for i, c := range testData.TestCases { t.Run(strconv.Itoa(i), func(t *testing.T) { assert := assert.New(t) - require := require.New(t) actual := flagengine.GetEvaluationResult(&c.EvaluationContext) expected := c.EvaluationResult - // Note: Flags are now a map, so no need to sort them - // The comparison will be done by comparing the map contents directly - - require.Len(actual.Flags, len(expected.Flags)) - for featureName, expectedFlag := range expected.Flags { - actualFlag, exists := actual.Flags[featureName] - require.True(exists, "Expected flag %s not found in actual result", featureName) - assert.Equal(expectedFlag.Value, actualFlag.Value) - assert.Equal(expectedFlag.Enabled, actualFlag.Enabled) - assert.Equal(expectedFlag.FeatureKey, actualFlag.FeatureKey) - } + assert.Equal(expected.Flags, actual.Flags) }) } } From 71ca9d9eb3d34d27afbfca10a13125ac78252e4d Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sun, 5 Oct 2025 14:50:46 +0530 Subject: [PATCH 27/56] misc --- flagengine/engine_eval/context.go | 4 ++-- flagengine/engine_eval/mappers.go | 31 +++++++++++++++++++------------ models_test.go | 25 ++++++++++++++++++------- 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/flagengine/engine_eval/context.go b/flagengine/engine_eval/context.go index 76875e16..eda04b8f 100644 --- a/flagengine/engine_eval/context.go +++ b/flagengine/engine_eval/context.go @@ -83,7 +83,7 @@ func (f *FlexibleString) UnmarshalJSON(data []byte) error { return nil } - return fmt.Errorf("unable to unmarshal FlexibleString from %s", string(data)) + return fmt.Errorf("unable to unmarshal FlexibleString: invalid format") } type IdentityContext struct { @@ -256,7 +256,7 @@ func (v *ValueUnion) UnmarshalJSON(data []byte) error { return nil } - return fmt.Errorf("unable to unmarshal ValueUnion from %s", string(data)) + return fmt.Errorf("unable to unmarshal ValueUnion: invalid format") } // UnmarshalJSON implements custom JSON unmarshaling for IdentityContext. diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index c4ecf125..fa85c1d0 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -63,6 +63,23 @@ func MapEnvironmentDocumentToEvaluationContext(env *environments.EnvironmentMode return ctx } +// mapMultivariateFeatureStateValuesToVariants converts multivariate feature state values to FeatureValue variants. +func mapMultivariateFeatureStateValuesToVariants(multivariateValues []*features.MultivariateFeatureStateValueModel) []FeatureValue { + if len(multivariateValues) == 0 { + return nil + } + + variants := make([]FeatureValue, 0, len(multivariateValues)) + for _, mv := range multivariateValues { + valueStr := fmt.Sprint(mv.MultivariateFeatureOption.Value) + variants = append(variants, FeatureValue{ + Value: &Value{String: &valueStr}, + Weight: mv.PercentageAllocation, + }) + } + return variants +} + func mapFeatureStateToFeatureContext(fs *features.FeatureStateModel) FeatureContext { var key string if fs.DjangoID != 0 { @@ -85,17 +102,7 @@ func mapFeatureStateToFeatureContext(fs *features.FeatureStateModel) FeatureCont } // Variants - if len(fs.MultivariateFeatureStateValues) > 0 { - variants := make([]FeatureValue, 0, len(fs.MultivariateFeatureStateValues)) - for _, mv := range fs.MultivariateFeatureStateValues { - valueStr := fmt.Sprint(mv.MultivariateFeatureOption.Value) - variants = append(variants, FeatureValue{ - Value: &Value{String: &valueStr}, - Weight: mv.PercentageAllocation, - }) - } - fc.Variants = variants - } + fc.Variants = mapMultivariateFeatureStateValuesToVariants(fs.MultivariateFeatureStateValues) // Priority (if present via segment override) if fs.FeatureSegment != nil { @@ -260,7 +267,7 @@ func mapIdentityOverridesToSegments(identityOverrides []*identities.IdentityMode // Create overrides for each feature for _, override := range overrides { - priority := math.Inf(-1) // Highest possible priority + priority := math.Inf(-1) // Strongest possible priority featureOverride := FeatureContext{ Key: "", // Identity overrides never carry multivariate options FeatureKey: override.featureKey, diff --git a/models_test.go b/models_test.go index 9daf6054..f4e59723 100644 --- a/models_test.go +++ b/models_test.go @@ -265,23 +265,34 @@ func TestMakeFlagsFromEngineEvaluationResult(t *testing.T) { return } - for i, expectedFlag := range tt.expected { - actualFlag := result.flags[i] + // Create a map of actual flags by feature name for order-independent comparison + actualFlagsByName := make(map[string]Flag) + for _, flag := range result.flags { + actualFlagsByName[flag.FeatureName] = flag + } + + // Compare each expected flag with the corresponding actual flag + for _, expectedFlag := range tt.expected { + actualFlag, exists := actualFlagsByName[expectedFlag.FeatureName] + if !exists { + t.Errorf("Expected flag %s not found in actual result", expectedFlag.FeatureName) + continue + } if actualFlag.Enabled != expectedFlag.Enabled { - t.Errorf("Flag %d: Expected Enabled %v, got %v", i, expectedFlag.Enabled, actualFlag.Enabled) + t.Errorf("Flag %s: Expected Enabled %v, got %v", expectedFlag.FeatureName, expectedFlag.Enabled, actualFlag.Enabled) } if actualFlag.Value != expectedFlag.Value { - t.Errorf("Flag %d: Expected Value %v, got %v", i, expectedFlag.Value, actualFlag.Value) + t.Errorf("Flag %s: Expected Value %v, got %v", expectedFlag.FeatureName, expectedFlag.Value, actualFlag.Value) } if actualFlag.IsDefault != expectedFlag.IsDefault { - t.Errorf("Flag %d: Expected IsDefault %v, got %v", i, expectedFlag.IsDefault, actualFlag.IsDefault) + t.Errorf("Flag %s: Expected IsDefault %v, got %v", expectedFlag.FeatureName, expectedFlag.IsDefault, actualFlag.IsDefault) } if actualFlag.FeatureID != expectedFlag.FeatureID { - t.Errorf("Flag %d: Expected FeatureID %v, got %v", i, expectedFlag.FeatureID, actualFlag.FeatureID) + t.Errorf("Flag %s: Expected FeatureID %v, got %v", expectedFlag.FeatureName, expectedFlag.FeatureID, actualFlag.FeatureID) } if actualFlag.FeatureName != expectedFlag.FeatureName { - t.Errorf("Flag %d: Expected FeatureName %v, got %v", i, expectedFlag.FeatureName, actualFlag.FeatureName) + t.Errorf("Flag %s: Expected FeatureName %v, got %v", expectedFlag.FeatureName, expectedFlag.FeatureName, actualFlag.FeatureName) } } From 27d620840b0d1bb24b0d833b423aa7c9258cb730 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 6 Oct 2025 08:51:04 +0530 Subject: [PATCH 28/56] use any --- flagengine/engine_eval/context.go | 57 +----------------- flagengine/engine_eval/evaluator.go | 25 +++----- flagengine/engine_eval/evaluator_test.go | 74 ++++++++++++------------ flagengine/engine_eval/mappers.go | 65 +++------------------ flagengine/engine_eval/mappers_test.go | 36 +++++++----- flagengine/engine_eval/result.go | 2 +- models.go | 11 +--- models_test.go | 50 ++++------------ 8 files changed, 89 insertions(+), 231 deletions(-) diff --git a/flagengine/engine_eval/context.go b/flagengine/engine_eval/context.go index eda04b8f..d22a1e09 100644 --- a/flagengine/engine_eval/context.go +++ b/flagengine/engine_eval/context.go @@ -43,7 +43,7 @@ type FeatureContext struct { Priority *float64 `json:"priority,omitempty"` // A default environment value for the feature. If the feature is multivariate, this will be // the control value. - Value *Value `json:"value"` + Value any `json:"value"` // An array of environment default values associated with the feature. Contains a single // value for standard features, or multiple values for multivariate features. Variants []FeatureValue `json:"variants,omitempty"` @@ -52,7 +52,7 @@ type FeatureContext struct { // Represents a multivariate value for a feature flag. type FeatureValue struct { // The value of the feature. - Value *Value `json:"value"` + Value any `json:"value"` // The weight of the feature value variant, as a percentage number (i.e. 100.0). Weight float64 `json:"weight"` } @@ -96,7 +96,7 @@ type IdentityContext struct { Key string `json:"key"` // A map of traits associated with the identity, where the key is the trait name and the // value is the trait value. - Traits map[string]*Value `json:"traits,omitempty"` + Traits map[string]any `json:"traits,omitempty"` } // Represents a segment context for feature flag evaluation. @@ -168,62 +168,11 @@ const ( // the control value. // // The value of the feature. -type Value struct { - Bool *bool - Double *float64 - String *string -} - type ValueUnion struct { String *string StringArray []string } -// UnmarshalJSON implements custom JSON unmarshaling for Value. -func (v *Value) UnmarshalJSON(data []byte) error { - // Try to unmarshal as null first - if string(data) == "null" { - return nil - } - - // Try to unmarshal as a structured object - var structured struct { - Bool *bool `json:"bool"` - Double *float64 `json:"double"` - String *string `json:"string"` - } - if err := json.Unmarshal(data, &structured); err == nil && (structured.Bool != nil || structured.Double != nil || structured.String != nil) { - v.Bool = structured.Bool - v.Double = structured.Double - v.String = structured.String - return nil - } - - // Try to unmarshal as a raw value - var rawValue interface{} - if err := json.Unmarshal(data, &rawValue); err != nil { - return err - } - - switch val := rawValue.(type) { - case bool: - v.Bool = &val - case float64: - v.Double = &val - case string: - v.String = &val - case nil: - // Already handled above, but just in case - return nil - default: - // If it's not a basic type, convert to string - str := fmt.Sprintf("%v", val) - v.String = &str - } - - return nil -} - // UnmarshalJSON implements custom JSON unmarshaling for ValueUnion. func (v *ValueUnion) UnmarshalJSON(data []byte) error { // Try to unmarshal as null first diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index 66d7288d..4c8e9015 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -67,12 +67,6 @@ func matchPercentageSplit(ec *EngineEvaluationContext, segmentCondition *Conditi switch v := contextValue.(type) { case string: strValue = v - case *Value: - if v != nil && v.String != nil { - strValue = *v.String - } else { - return false - } default: return false } @@ -174,17 +168,14 @@ func ToString(contextValue ContextValue) string { if s, ok := contextValue.(string); ok { return s } - // Handle *Value type - if v, ok := contextValue.(*Value); ok && v != nil { - if v.String != nil { - return *v.String - } - if v.Bool != nil { - return strconv.FormatBool(*v.Bool) - } - if v.Double != nil { - return strconv.FormatFloat(*v.Double, 'f', -1, 64) - } + if b, ok := contextValue.(bool); ok { + return strconv.FormatBool(b) + } + if f, ok := contextValue.(float64); ok { + return strconv.FormatFloat(f, 'f', -1, 64) + } + if i, ok := contextValue.(int); ok { + return strconv.Itoa(i) } return fmt.Sprint(contextValue) } diff --git a/flagengine/engine_eval/evaluator_test.go b/flagengine/engine_eval/evaluator_test.go index 179ce954..0e9f0a76 100644 --- a/flagengine/engine_eval/evaluator_test.go +++ b/flagengine/engine_eval/evaluator_test.go @@ -21,17 +21,17 @@ const ( traitValue3 = "2021-01-01" ) -// Helper function to create a Value pointer. -func stringValue(s string) *engine_eval.Value { - return &engine_eval.Value{String: &s} +// Helper function to create a string value. +func stringValue(s string) string { + return s } -func boolValue(b bool) *engine_eval.Value { - return &engine_eval.Value{Bool: &b} +func boolValue(b bool) bool { + return b } -func doubleValue(d float64) *engine_eval.Value { - return &engine_eval.Value{Double: &d} +func doubleValue(d float64) float64 { + return d } // Helper function to create string pointer. @@ -40,7 +40,7 @@ func stringPtr(s string) *string { } // Helper function to create evaluation context with traits. -func createEvaluationContext(traits map[string]*engine_eval.Value) *engine_eval.EngineEvaluationContext { +func createEvaluationContext(traits map[string]any) *engine_eval.EngineEvaluationContext { return &engine_eval.EngineEvaluationContext{ Environment: engine_eval.EnvironmentContext{ Key: "test-env", @@ -92,7 +92,7 @@ func TestIsContextInSegment(t *testing.T) { }, }, }), - evalContext: createEvaluationContext(map[string]*engine_eval.Value{ + evalContext: createEvaluationContext(map[string]any{ traitKey1: stringValue(traitValue1), }), expected: true, @@ -111,7 +111,7 @@ func TestIsContextInSegment(t *testing.T) { }, }, }), - evalContext: createEvaluationContext(map[string]*engine_eval.Value{ + evalContext: createEvaluationContext(map[string]any{ traitKey1: stringValue("different@example.com"), }), expected: false, @@ -135,7 +135,7 @@ func TestIsContextInSegment(t *testing.T) { }, }, }), - evalContext: createEvaluationContext(map[string]*engine_eval.Value{ + evalContext: createEvaluationContext(map[string]any{ traitKey1: stringValue(traitValue1), traitKey2: stringValue(traitValue2), }), @@ -160,7 +160,7 @@ func TestIsContextInSegment(t *testing.T) { }, }, }), - evalContext: createEvaluationContext(map[string]*engine_eval.Value{ + evalContext: createEvaluationContext(map[string]any{ traitKey1: stringValue(traitValue1), traitKey2: stringValue("different_value"), }), @@ -185,7 +185,7 @@ func TestIsContextInSegment(t *testing.T) { }, }, }), - evalContext: createEvaluationContext(map[string]*engine_eval.Value{ + evalContext: createEvaluationContext(map[string]any{ traitKey1: stringValue(traitValue1), traitKey2: stringValue("different_value"), }), @@ -225,7 +225,7 @@ func TestIsContextInSegment(t *testing.T) { }, }, }), - evalContext: createEvaluationContext(map[string]*engine_eval.Value{ + evalContext: createEvaluationContext(map[string]any{ traitKey1: stringValue(traitValue1), traitKey2: stringValue(traitValue2), traitKey3: stringValue(traitValue3), @@ -299,20 +299,20 @@ func TestContextMatchesCondition(t *testing.T) { Value: &engine_eval.ValueUnion{String: stringPtr(c.conditionValue)}, } - var traitValuePtr *engine_eval.Value + var traitValue any switch v := c.traitValue.(type) { case string: - traitValuePtr = stringValue(v) + traitValue = stringValue(v) case bool: - traitValuePtr = boolValue(v) + traitValue = boolValue(v) case float64: - traitValuePtr = doubleValue(v) + traitValue = doubleValue(v) default: - traitValuePtr = stringValue(fmt.Sprint(v)) + traitValue = stringValue(fmt.Sprint(v)) } - evalContext := createEvaluationContext(map[string]*engine_eval.Value{ - c.property: traitValuePtr, + evalContext := createEvaluationContext(map[string]any{ + c.property: traitValue, }) // We need to access the internal function, so we'll test via IsContextInSegment @@ -356,7 +356,7 @@ func TestContextMatchesConditionInOperatorStringArray(t *testing.T) { traitValuePtr := stringValue(c.traitValue) - evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + evalContext := createEvaluationContext(map[string]any{ traitKey1: traitValuePtr, }) @@ -397,9 +397,9 @@ func TestContextMatchesConditionIsSetAndIsNotSet(t *testing.T) { Property: c.property, } - var traits map[string]*engine_eval.Value + var traits map[string]any if c.hasProperty { - traits = map[string]*engine_eval.Value{ + traits = map[string]any{ c.property: stringValue("some_value"), } } @@ -468,7 +468,7 @@ func TestGetContextValueIntegration(t *testing.T) { // This tests that the function works correctly in the context it's used t.Run("simple trait lookup works", func(t *testing.T) { - evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + evalContext := createEvaluationContext(map[string]any{ "email": stringValue("test@example.com"), }) @@ -537,7 +537,7 @@ func TestToStringIntegration(t *testing.T) { // This tests that the function works correctly in the context it's used t.Run("string values work correctly", func(t *testing.T) { - evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + evalContext := createEvaluationContext(map[string]any{ "test_prop": stringValue("test_string"), }) @@ -559,7 +559,7 @@ func TestToStringIntegration(t *testing.T) { }) t.Run("boolean values work correctly", func(t *testing.T) { - evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + evalContext := createEvaluationContext(map[string]any{ "test_prop": boolValue(true), }) @@ -581,7 +581,7 @@ func TestToStringIntegration(t *testing.T) { }) t.Run("numeric values work correctly", func(t *testing.T) { - evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + evalContext := createEvaluationContext(map[string]any{ "test_prop": doubleValue(123.45), }) @@ -652,7 +652,7 @@ func TestSemverComparisons(t *testing.T) { Value: &engine_eval.ValueUnion{String: stringPtr(c.conditionValue)}, } - evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + evalContext := createEvaluationContext(map[string]any{ "version": stringValue(c.traitValue), }) @@ -710,7 +710,7 @@ func TestComplexSegmentRules(t *testing.T) { }) // Should match when all conditions are met - evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + evalContext := createEvaluationContext(map[string]any{ traitKey1: stringValue(traitValue1), traitKey2: stringValue(traitValue2), traitKey3: stringValue(traitValue3), @@ -720,7 +720,7 @@ func TestComplexSegmentRules(t *testing.T) { assert.True(t, result) // Should not match when one condition fails - evalContextPartial := createEvaluationContext(map[string]*engine_eval.Value{ + evalContextPartial := createEvaluationContext(map[string]any{ traitKey1: stringValue(traitValue1), traitKey2: stringValue(traitValue2), // Missing traitKey3 @@ -750,7 +750,7 @@ func TestComplexSegmentRules(t *testing.T) { }) // Should match when no conditions are met (NONE rule) - evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + evalContext := createEvaluationContext(map[string]any{ traitKey1: stringValue("different1"), traitKey2: stringValue("different2"), }) @@ -759,7 +759,7 @@ func TestComplexSegmentRules(t *testing.T) { assert.True(t, result) // Should not match when any condition is met - evalContextWithMatch := createEvaluationContext(map[string]*engine_eval.Value{ + evalContextWithMatch := createEvaluationContext(map[string]any{ traitKey1: stringValue(traitValue1), // This matches traitKey2: stringValue("different2"), }) @@ -874,7 +874,7 @@ func TestRegexOperator(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { - evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + evalContext := createEvaluationContext(map[string]any{ "test_trait": stringValue(c.traitValue), }) @@ -922,7 +922,7 @@ func TestModuloOperator(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { - evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + evalContext := createEvaluationContext(map[string]any{ "test_trait": stringValue(c.traitValue), }) @@ -948,7 +948,7 @@ func TestModuloOperator(t *testing.T) { func TestMatchWithRegexOperator(t *testing.T) { t.Parallel() - evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + evalContext := createEvaluationContext(map[string]any{ "email": stringValue("test@example.com"), }) @@ -972,7 +972,7 @@ func TestMatchWithRegexOperator(t *testing.T) { func TestMatchWithModuloOperator(t *testing.T) { t.Parallel() - evalContext := createEvaluationContext(map[string]*engine_eval.Value{ + evalContext := createEvaluationContext(map[string]any{ "user_id": stringValue("35"), }) diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index fa85c1d0..68495fd5 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -71,9 +71,8 @@ func mapMultivariateFeatureStateValuesToVariants(multivariateValues []*features. variants := make([]FeatureValue, 0, len(multivariateValues)) for _, mv := range multivariateValues { - valueStr := fmt.Sprint(mv.MultivariateFeatureOption.Value) variants = append(variants, FeatureValue{ - Value: &Value{String: &valueStr}, + Value: mv.MultivariateFeatureOption.Value, Weight: mv.PercentageAllocation, }) } @@ -97,8 +96,7 @@ func mapFeatureStateToFeatureContext(fs *features.FeatureStateModel) FeatureCont // Value if fs.RawValue != nil { - valueStr := fmt.Sprint(fs.RawValue) - fc.Value = &Value{String: &valueStr} + fc.Value = fs.RawValue } // Variants @@ -278,7 +276,7 @@ func mapIdentityOverridesToSegments(identityOverrides []*identities.IdentityMode // Set the value if provided if override.featureValue != "" { - featureOverride.Value = &Value{String: &override.featureValue} + featureOverride.Value = override.featureValue } sc.Overrides = append(sc.Overrides, featureOverride) @@ -328,17 +326,16 @@ func MapContextAndIdentityDataToContext( newContext := context // Create traits map for the identity - identityTraits := make(map[string]*Value) + identityTraits := make(map[string]any) for _, trait := range traitList { if trait == nil { continue } - // Convert trait value to *Value - valuePtr := convertTraitValueToValue(trait.TraitValue) - if valuePtr != nil { - identityTraits[trait.TraitKey] = valuePtr + // Store trait value directly as any + if trait.TraitValue != nil { + identityTraits[trait.TraitKey] = trait.TraitValue } } @@ -356,54 +353,6 @@ func MapContextAndIdentityDataToContext( return newContext } -// This function handles interface{} values and converts them appropriately. -func convertTraitValueToValue(traitValue interface{}) *Value { - if traitValue == nil { - return nil - } - - switch v := traitValue.(type) { - case bool: - return &Value{Bool: &v} - case int: - f := float64(v) - return &Value{Double: &f} - case int64: - f := float64(v) - return &Value{Double: &f} - case float64: - return &Value{Double: &v} - case float32: - f := float64(v) - return &Value{Double: &f} - case string: - if v == "" { - return nil - } - // Try to parse string as boolean - if v == "true" { - b := true - return &Value{Bool: &b} - } else if v == "false" { - b := false - return &Value{Bool: &b} - } - // Try to parse string as float64 - if f, err := strconv.ParseFloat(v, 64); err == nil { - return &Value{Double: &f} - } - // Default to string - return &Value{String: &v} - default: - // For other types, convert to string - str := fmt.Sprint(v) - if str == "" { - return nil - } - return &Value{String: &str} - } -} - // MapEvaluationResultSegmentsToSegmentModels converts evaluation result segments // to segments.SegmentModel with only ID and Name populated. func MapEvaluationResultSegmentsToSegmentModels( diff --git a/flagengine/engine_eval/mappers_test.go b/flagengine/engine_eval/mappers_test.go index 4a01992d..a07652fb 100644 --- a/flagengine/engine_eval/mappers_test.go +++ b/flagengine/engine_eval/mappers_test.go @@ -106,8 +106,10 @@ func TestMapEnvironmentDocumentToEvaluationContext(t *testing.T) { if testFeature.Key != "123" { t.Errorf("Expected Key to be '123' (from DjangoID), got %v", testFeature.Key) } - if testFeature.Value == nil || testFeature.Value.String == nil || *testFeature.Value.String != "test-value" { - t.Errorf("Expected Value.String to be 'test-value', got %v", testFeature.Value) + if testFeature.Value == nil { + t.Error("Expected Value to be set") + } else if valueStr, ok := testFeature.Value.(string); !ok || valueStr != "test-value" { + t.Errorf("Expected Value to be 'test-value', got %v", testFeature.Value) } } @@ -175,7 +177,9 @@ func TestMapEnvironmentDocumentToEvaluationContext(t *testing.T) { if !override.Enabled { t.Error("Expected segment override to be enabled") } - if override.Value == nil || override.Value.String == nil || *override.Value.String != "segment-value" { + if override.Value == nil { + t.Error("Expected override Value to be set") + } else if valueStr, ok := override.Value.(string); !ok || valueStr != "segment-value" { t.Errorf("Expected override value to be 'segment-value', got %v", override.Value) } } @@ -431,55 +435,57 @@ func TestMapContextAndIdentityDataToContext(t *testing.T) { // Test string trait if stringTrait, exists := identity.Traits["string_trait"]; !exists { t.Error("Expected string_trait to exist") - } else if stringTrait.String == nil || *stringTrait.String != "string_value" { + } else if stringTrait != "string_value" { t.Errorf("Expected string_trait to be 'string_value', got %v", stringTrait) } - // Test int trait (int 42 converted to float64) + // Test int trait if intTrait, exists := identity.Traits["int_trait"]; !exists { t.Error("Expected int_trait to exist") - } else if intTrait.Double == nil || *intTrait.Double != 42.0 { - t.Errorf("Expected int_trait to be 42.0, got %v", intTrait) + } else if intTrait != 42 { + t.Errorf("Expected int_trait to be 42, got %v", intTrait) } // Test float trait (float64 3.14) if floatTrait, exists := identity.Traits["float_trait"]; !exists { t.Error("Expected float_trait to exist") - } else if floatTrait.Double == nil || *floatTrait.Double != 3.14 { + } else if floatTrait != 3.14 { t.Errorf("Expected float_trait to be 3.14, got %v", floatTrait) } // Test bool true trait (bool true) if boolTrueTrait, exists := identity.Traits["bool_true_trait"]; !exists { t.Error("Expected bool_true_trait to exist") - } else if boolTrueTrait.Bool == nil || *boolTrueTrait.Bool != true { + } else if boolTrueTrait != true { t.Errorf("Expected bool_true_trait to be true, got %v", boolTrueTrait) } // Test bool false trait (bool false) if boolFalseTrait, exists := identity.Traits["bool_false_trait"]; !exists { t.Error("Expected bool_false_trait to exist") - } else if boolFalseTrait.Bool == nil || *boolFalseTrait.Bool != false { + } else if boolFalseTrait != false { t.Errorf("Expected bool_false_trait to be false, got %v", boolFalseTrait) } // Test string number trait (string "99" parsed as float64) if stringNumberTrait, exists := identity.Traits["string_number_trait"]; !exists { t.Error("Expected string_number_trait to exist") - } else if stringNumberTrait.Double == nil || *stringNumberTrait.Double != 99.0 { + } else if stringNumberTrait != "99" { t.Errorf("Expected string_number_trait to be 99.0, got %v", stringNumberTrait) } // Test string bool trait (string "true" parsed as bool) if stringBoolTrait, exists := identity.Traits["string_bool_trait"]; !exists { t.Error("Expected string_bool_trait to exist") - } else if stringBoolTrait.Bool == nil || *stringBoolTrait.Bool != true { + } else if stringBoolTrait != "true" { t.Errorf("Expected string_bool_trait to be true, got %v", stringBoolTrait) } - // Test empty trait (should not be included) - if _, exists := identity.Traits["empty_trait"]; exists { - t.Error("Expected empty_trait to not be included") + // Test empty trait (should be included as empty string is a valid value) + if emptyTrait, exists := identity.Traits["empty_trait"]; !exists { + t.Error("Expected empty_trait to be included") + } else if emptyStr, ok := emptyTrait.(string); !ok || emptyStr != "" { + t.Errorf("Expected empty_trait to be empty string, got %v", emptyTrait) } } diff --git a/flagengine/engine_eval/result.go b/flagengine/engine_eval/result.go index fd06a81f..fb9d5c89 100644 --- a/flagengine/engine_eval/result.go +++ b/flagengine/engine_eval/result.go @@ -19,7 +19,7 @@ type FlagResult struct { // Reason for the feature flag evaluation. Reason *string `json:"reason,omitempty"` // Feature flag value. - Value *Value `json:"value,omitempty"` + Value any `json:"value,omitempty"` } type SegmentResult struct { diff --git a/models.go b/models.go index 59f5a163..e2c3890b 100644 --- a/models.go +++ b/models.go @@ -36,16 +36,7 @@ func (t *Trait) ToTraitModel() *traits.TraitModel { } } func makeFlagFromEngineEvaluationFlagResult(flagResult *engine_eval.FlagResult) Flag { - var value interface{} - if flagResult.Value != nil { - if flagResult.Value.String != nil { - value = *flagResult.Value.String - } else if flagResult.Value.Bool != nil { - value = *flagResult.Value.Bool - } else if flagResult.Value.Double != nil { - value = *flagResult.Value.Double - } - } + value := flagResult.Value // Convert FeatureKey (string ID) to integer FeatureID featureID := 0 diff --git a/models_test.go b/models_test.go index f4e59723..ea57ed33 100644 --- a/models_test.go +++ b/models_test.go @@ -18,9 +18,7 @@ func TestMakeFlagFromEngineEvaluationFlagResult(t *testing.T) { Enabled: true, FeatureKey: "test_feature_key", Name: "test_feature", - Value: &engine_eval.Value{ - String: stringPtr("test_value"), - }, + Value: "test_value", }, expected: Flag{ Enabled: true, @@ -36,9 +34,7 @@ func TestMakeFlagFromEngineEvaluationFlagResult(t *testing.T) { Enabled: false, FeatureKey: "bool_feature_key", Name: "bool_feature", - Value: &engine_eval.Value{ - Bool: boolPtr(true), - }, + Value: true, }, expected: Flag{ Enabled: false, @@ -54,9 +50,7 @@ func TestMakeFlagFromEngineEvaluationFlagResult(t *testing.T) { Enabled: true, FeatureKey: "double_feature_key", Name: "double_feature", - Value: &engine_eval.Value{ - Double: float64Ptr(42.5), - }, + Value: 42.5, }, expected: Flag{ Enabled: true, @@ -88,7 +82,7 @@ func TestMakeFlagFromEngineEvaluationFlagResult(t *testing.T) { Enabled: false, FeatureKey: "empty_feature_key", Name: "empty_feature", - Value: &engine_eval.Value{}, + Value: nil, }, expected: Flag{ Enabled: false, @@ -104,9 +98,7 @@ func TestMakeFlagFromEngineEvaluationFlagResult(t *testing.T) { Enabled: false, FeatureKey: "", Name: "", - Value: &engine_eval.Value{ - String: stringPtr(""), - }, + Value: "", }, expected: Flag{ Enabled: false, @@ -123,9 +115,7 @@ func TestMakeFlagFromEngineEvaluationFlagResult(t *testing.T) { FeatureKey: "reason_feature_key", Name: "reason_feature", Reason: stringPtr("TARGETING_MATCH"), - Value: &engine_eval.Value{ - String: stringPtr("reason_value"), - }, + Value: "reason_value", }, expected: Flag{ Enabled: true, @@ -174,25 +164,19 @@ func TestMakeFlagsFromEngineEvaluationResult(t *testing.T) { Enabled: true, FeatureKey: "feature1_key", Name: "feature1", - Value: &engine_eval.Value{ - String: stringPtr("value1"), - }, + Value: "value1", }, "feature2": { Enabled: false, FeatureKey: "feature2_key", Name: "feature2", - Value: &engine_eval.Value{ - Bool: boolPtr(true), - }, + Value: true, }, "feature3": { Enabled: true, FeatureKey: "feature3_key", Name: "feature3", - Value: &engine_eval.Value{ - Double: float64Ptr(123.45), - }, + Value: 123.45, }, }, Segments: []engine_eval.SegmentResult{}, @@ -237,9 +221,7 @@ func TestMakeFlagsFromEngineEvaluationResult(t *testing.T) { Enabled: true, FeatureKey: "single_feature_key", Name: "single_feature", - Value: &engine_eval.Value{ - String: stringPtr("single_value"), - }, + Value: "single_value", }, }, Segments: []engine_eval.SegmentResult{}, @@ -328,9 +310,7 @@ func TestMakeFlagsFromEngineEvaluationResultWithProcessorAndHandler(t *testing.T Enabled: true, FeatureKey: "test_feature_key", Name: "test_feature", - Value: &engine_eval.Value{ - String: stringPtr("test_value"), - }, + Value: "test_value", }, }, Segments: []engine_eval.SegmentResult{}, @@ -365,11 +345,3 @@ func TestMakeFlagsFromEngineEvaluationResultWithProcessorAndHandler(t *testing.T func stringPtr(s string) *string { return &s } - -func boolPtr(b bool) *bool { - return &b -} - -func float64Ptr(f float64) *float64 { - return &f -} From 23195eef30c8862dcc61bd70b6d5eeb290d1408e Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 6 Oct 2025 12:07:24 +0530 Subject: [PATCH 29/56] use generic for segment operator --- flagengine/engine_eval/evaluator.go | 166 +------------- flagengine/engine_eval/generic_evaluator.go | 206 ++++++++++++++++++ .../engine_eval/generic_evaluator_test.go | 156 +++++++++++++ 3 files changed, 363 insertions(+), 165 deletions(-) create mode 100644 flagengine/engine_eval/generic_evaluator.go create mode 100644 flagengine/engine_eval/generic_evaluator_test.go diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index 4c8e9015..2ae05bca 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -2,14 +2,11 @@ package engine_eval import ( "fmt" - "math" - "regexp" "slices" "strconv" "strings" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" - "github.com/blang/semver/v4" "github.com/ohler55/ojg/jp" ) @@ -102,7 +99,7 @@ func contextMatchesCondition(ec *EngineEvaluationContext, segmentCondition *Cond return contextValue != nil } if contextValue != nil { - return match(segmentCondition.Operator, ToString(contextValue), *segmentCondition.Value.String) + return parseAndMatch(segmentCondition.Operator, ToString(contextValue), *segmentCondition.Value.String) } return false } @@ -179,164 +176,3 @@ func ToString(contextValue ContextValue) string { } return fmt.Sprint(contextValue) } - -func match(c Operator, traitValue, conditionValue string) bool { - // Handle special operators first - switch c { - case Modulo: - return matchModulo(traitValue, conditionValue) - case Regex: - return matchRegex(traitValue, conditionValue) - } - - b1, e1 := strconv.ParseBool(traitValue) - b2, e2 := strconv.ParseBool(conditionValue) - if e1 == nil && e2 == nil { - return matchBool(c, b1, b2) - } - - i1, e1 := strconv.ParseInt(traitValue, 10, 64) - i2, e2 := strconv.ParseInt(conditionValue, 10, 64) - if e1 == nil && e2 == nil { - return matchInt(c, i1, i2) - } - - f1, e1 := strconv.ParseFloat(traitValue, 64) - f2, e2 := strconv.ParseFloat(conditionValue, 64) - if e1 == nil && e2 == nil { - return matchFloat(c, f1, f2) - } - if strings.HasSuffix(conditionValue, ":semver") { - conditionVersion, err := semver.Make(conditionValue[:len(conditionValue)-7]) - if err != nil { - return false - } - return matchSemver(c, traitValue, conditionVersion) - } - return matchString(c, traitValue, conditionValue) -} - -func matchSemver(c Operator, traitValue string, conditionVersion semver.Version) bool { - traitVersion, err := semver.Make(traitValue) - if err != nil { - return false - } - switch c { - case Equal: - return traitVersion.EQ(conditionVersion) - case GreaterThan: - return traitVersion.GT(conditionVersion) - case LessThan: - return traitVersion.LT(conditionVersion) - case LessThanInclusive: - return traitVersion.LTE(conditionVersion) - case GreaterThanInclusive: - return traitVersion.GE(conditionVersion) - case NotEqual: - return traitVersion.NE(conditionVersion) - } - return false -} - -func matchBool(c Operator, v1, v2 bool) bool { - var i1, i2 int64 - if v1 { - i1 = 1 - } - if v2 { - i2 = 1 - } - return matchInt(c, i1, i2) -} - -func matchInt(c Operator, v1, v2 int64) bool { - switch c { - case Equal: - return v1 == v2 - case GreaterThan: - return v1 > v2 - case LessThan: - return v1 < v2 - case LessThanInclusive: - return v1 <= v2 - case GreaterThanInclusive: - return v1 >= v2 - case NotEqual: - return v1 != v2 - } - return v1 == v2 -} - -func matchFloat(c Operator, v1, v2 float64) bool { - switch c { - case Equal: - return v1 == v2 - case GreaterThan: - return v1 > v2 - case LessThan: - return v1 < v2 - case LessThanInclusive: - return v1 <= v2 - case GreaterThanInclusive: - return v1 >= v2 - case NotEqual: - return v1 != v2 - } - return v1 == v2 -} - -func matchString(c Operator, v1, v2 string) bool { - switch c { - case Contains: - return strings.Contains(v1, v2) - case NotContains: - return !strings.Contains(v1, v2) - case Equal: - return v1 == v2 - case GreaterThan: - return v1 > v2 - case LessThan: - return v1 < v2 - case LessThanInclusive: - return v1 <= v2 - case GreaterThanInclusive: - return v1 >= v2 - case NotEqual: - return v1 != v2 - } - return v1 == v2 -} - -// matchRegex performs regex matching on trait values. -func matchRegex(traitValue, conditionValue string) bool { - match, err := regexp.Match(conditionValue, []byte(traitValue)) - if err != nil { - return false - } - return match -} - -// matchModulo performs modulo operation matching on trait values. -func matchModulo(traitValue, conditionValue string) bool { - values := strings.Split(conditionValue, "|") - if len(values) != 2 { - return false - } - - divisor, err := strconv.ParseFloat(values[0], 64) - if err != nil { - return false - } - - remainder, err := strconv.ParseFloat(values[1], 64) - if err != nil { - return false - } - - traitValueFloat, err := strconv.ParseFloat(traitValue, 64) - if err != nil { - return false - } - - return math.Mod(traitValueFloat, divisor) == remainder -} diff --git a/flagengine/engine_eval/generic_evaluator.go b/flagengine/engine_eval/generic_evaluator.go new file mode 100644 index 00000000..dcf15aa4 --- /dev/null +++ b/flagengine/engine_eval/generic_evaluator.go @@ -0,0 +1,206 @@ +package engine_eval + +import ( + "math" + "regexp" + "strconv" + "strings" + + "github.com/blang/semver/v4" +) + +// Comparable defines types that can be compared using standard operators. +// This includes all numeric types, strings, and booleans. +type Comparable interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | + ~float32 | ~float64 | + ~string | ~bool +} + +// Ordered defines types that support ordering operations (>, <, >=, <=). +// Note that bool is excluded as it doesn't support ordering. +type Ordered interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | + ~float32 | ~float64 | + ~string +} + +// Generic comparison functions - one per operator + +// evaluateEqualGeneric implements the EQUAL operator for comparable types. +func evaluateEqualGeneric[T Comparable](v1, v2 T) bool { + return v1 == v2 +} + +// evaluateNotEqualGeneric implements the NOT_EQUAL operator for comparable types. +func evaluateNotEqualGeneric[T Comparable](v1, v2 T) bool { + return v1 != v2 +} + +// evaluateGreaterThanGeneric implements the GREATER_THAN operator for ordered types. +func evaluateGreaterThanGeneric[T Ordered](v1, v2 T) bool { + return v1 > v2 +} + +// evaluateLessThanGeneric implements the LESS_THAN operator for ordered types. +func evaluateLessThanGeneric[T Ordered](v1, v2 T) bool { + return v1 < v2 +} + +// evaluateGreaterThanInclusiveGeneric implements the GREATER_THAN_INCLUSIVE operator for ordered types. +func evaluateGreaterThanInclusiveGeneric[T Ordered](v1, v2 T) bool { + return v1 >= v2 +} + +// evaluateLessThanInclusiveGeneric implements the LESS_THAN_INCLUSIVE operator for ordered types. +func evaluateLessThanInclusiveGeneric[T Ordered](v1, v2 T) bool { + return v1 <= v2 +} + +// evaluateContainsGeneric implements the CONTAINS operator for strings. +func evaluateContainsGeneric(v1, v2 string) bool { + return strings.Contains(v1, v2) +} + +// evaluateNotContainsGeneric implements the NOT_CONTAINS operator for strings. +func evaluateNotContainsGeneric(v1, v2 string) bool { + return !strings.Contains(v1, v2) +} + +// dispatchOperator dispatches the operator to the appropriate generic function. +func dispatchOperator[T Ordered](operator Operator, v1, v2 T) bool { + switch operator { + case Equal: + return evaluateEqualGeneric(v1, v2) + case NotEqual: + return evaluateNotEqualGeneric(v1, v2) + case GreaterThan: + return evaluateGreaterThanGeneric(v1, v2) + case LessThan: + return evaluateLessThanGeneric(v1, v2) + case GreaterThanInclusive: + return evaluateGreaterThanInclusiveGeneric(v1, v2) + case LessThanInclusive: + return evaluateLessThanInclusiveGeneric(v1, v2) + } + return false +} + +// dispatchComparableOperator dispatches equality operators for comparable types (including bool). +func dispatchComparableOperator[T Comparable](operator Operator, v1, v2 T) bool { + switch operator { + case Equal: + return evaluateEqualGeneric(v1, v2) + case NotEqual: + return evaluateNotEqualGeneric(v1, v2) + } + return false +} + +// parseAndMatch attempts to parse string values into specific types and compare them using generics. +func parseAndMatch(operator Operator, traitValue, conditionValue string) bool { + // Handle special operators first + switch operator { + case Modulo: + return evaluateModuloGeneric(traitValue, conditionValue) + case Regex: + return evaluateRegexGeneric(traitValue, conditionValue) + case Contains: + return evaluateContainsGeneric(traitValue, conditionValue) + case NotContains: + return evaluateNotContainsGeneric(traitValue, conditionValue) + } + + // Handle semver comparison + if strings.HasSuffix(conditionValue, ":semver") { + conditionVersion, err := semver.Make(conditionValue[:len(conditionValue)-7]) + if err != nil { + return false + } + return evaluateSemverGeneric(operator, traitValue, conditionVersion) + } + + // Try boolean parsing + if b1, e1 := strconv.ParseBool(traitValue); e1 == nil { + if b2, e2 := strconv.ParseBool(conditionValue); e2 == nil { + return dispatchComparableOperator(operator, b1, b2) + } + } + + // Try integer parsing + if i1, e1 := strconv.ParseInt(traitValue, 10, 64); e1 == nil { + if i2, e2 := strconv.ParseInt(conditionValue, 10, 64); e2 == nil { + return dispatchOperator(operator, i1, i2) + } + } + + // Try float parsing + if f1, e1 := strconv.ParseFloat(traitValue, 64); e1 == nil { + if f2, e2 := strconv.ParseFloat(conditionValue, 64); e2 == nil { + return dispatchOperator(operator, f1, f2) + } + } + + // Fall back to string comparison + return dispatchOperator(operator, traitValue, conditionValue) +} + +// evaluateRegexGeneric performs regex matching on trait values. +func evaluateRegexGeneric(traitValue, conditionValue string) bool { + match, err := regexp.Match(conditionValue, []byte(traitValue)) + if err != nil { + return false + } + return match +} + +// evaluateModuloGeneric performs modulo operation matching on trait values. +func evaluateModuloGeneric(traitValue, conditionValue string) bool { + values := strings.Split(conditionValue, "|") + if len(values) != 2 { + return false + } + + divisor, err := strconv.ParseFloat(values[0], 64) + if err != nil { + return false + } + + remainder, err := strconv.ParseFloat(values[1], 64) + if err != nil { + return false + } + + traitValueFloat, err := strconv.ParseFloat(traitValue, 64) + if err != nil { + return false + } + + return math.Mod(traitValueFloat, divisor) == remainder +} + +// evaluateSemverGeneric handles semantic version comparisons. +func evaluateSemverGeneric(operator Operator, traitValue string, conditionVersion semver.Version) bool { + traitVersion, err := semver.Make(traitValue) + if err != nil { + return false + } + + switch operator { + case Equal: + return traitVersion.EQ(conditionVersion) + case NotEqual: + return traitVersion.NE(conditionVersion) + case GreaterThan: + return traitVersion.GT(conditionVersion) + case LessThan: + return traitVersion.LT(conditionVersion) + case GreaterThanInclusive: + return traitVersion.GE(conditionVersion) + case LessThanInclusive: + return traitVersion.LTE(conditionVersion) + } + return false +} diff --git a/flagengine/engine_eval/generic_evaluator_test.go b/flagengine/engine_eval/generic_evaluator_test.go new file mode 100644 index 00000000..71948945 --- /dev/null +++ b/flagengine/engine_eval/generic_evaluator_test.go @@ -0,0 +1,156 @@ +package engine_eval + +import ( + "testing" +) + +func TestMatchGeneric(t *testing.T) { + tests := []struct { + name string + operator Operator + v1 any + v2 any + expected bool + }{ + // Boolean tests + {"bool equal true", Equal, true, true, true}, + {"bool equal false", Equal, false, false, true}, + {"bool not equal", Equal, true, false, false}, + {"bool not equal operator", NotEqual, true, false, true}, + + // Integer tests + {"int equal", Equal, int64(42), int64(42), true}, + {"int not equal", Equal, int64(42), int64(43), false}, + {"int greater than", GreaterThan, int64(43), int64(42), true}, + {"int less than", LessThan, int64(42), int64(43), true}, + {"int greater than equal", GreaterThanInclusive, int64(42), int64(42), true}, + {"int less than equal", LessThanInclusive, int64(42), int64(42), true}, + + // Float tests + {"float equal", Equal, 42.5, 42.5, true}, + {"float not equal", Equal, 42.5, 42.6, false}, + {"float greater than", GreaterThan, 42.6, 42.5, true}, + {"float less than", LessThan, 42.5, 42.6, true}, + + // String tests + {"string equal", Equal, "hello", "hello", true}, + {"string not equal", Equal, "hello", "world", false}, + {"string greater than", GreaterThan, "world", "hello", true}, + {"string less than", LessThan, "hello", "world", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var result bool + switch v1 := tt.v1.(type) { + case bool: + if v2, ok := tt.v2.(bool); ok { + switch tt.operator { + case Equal: + result = evaluateEqualGeneric(v1, v2) + case NotEqual: + result = evaluateNotEqualGeneric(v1, v2) + } + } + case int64: + if v2, ok := tt.v2.(int64); ok { + switch tt.operator { + case Equal: + result = evaluateEqualGeneric(v1, v2) + case NotEqual: + result = evaluateNotEqualGeneric(v1, v2) + case GreaterThan: + result = evaluateGreaterThanGeneric(v1, v2) + case LessThan: + result = evaluateLessThanGeneric(v1, v2) + case GreaterThanInclusive: + result = evaluateGreaterThanInclusiveGeneric(v1, v2) + case LessThanInclusive: + result = evaluateLessThanInclusiveGeneric(v1, v2) + } + } + case float64: + if v2, ok := tt.v2.(float64); ok { + switch tt.operator { + case Equal: + result = evaluateEqualGeneric(v1, v2) + case NotEqual: + result = evaluateNotEqualGeneric(v1, v2) + case GreaterThan: + result = evaluateGreaterThanGeneric(v1, v2) + case LessThan: + result = evaluateLessThanGeneric(v1, v2) + } + } + case string: + if v2, ok := tt.v2.(string); ok { + switch tt.operator { + case Equal: + result = evaluateEqualGeneric(v1, v2) + case NotEqual: + result = evaluateNotEqualGeneric(v1, v2) + case GreaterThan: + result = evaluateGreaterThanGeneric(v1, v2) + case LessThan: + result = evaluateLessThanGeneric(v1, v2) + } + } + } + + if result != tt.expected { + t.Errorf("evaluateGeneric(%v, %v, %v) = %v, want %v", tt.operator, tt.v1, tt.v2, result, tt.expected) + } + }) + } +} + +func TestParseAndMatch(t *testing.T) { + tests := []struct { + name string + operator Operator + traitValue string + conditionValue string + expected bool + }{ + // Boolean parsing and comparison + {"parse bool equal true", Equal, "true", "true", true}, + {"parse bool equal false", Equal, "false", "false", true}, + {"parse bool not equal", Equal, "true", "false", false}, + {"parse bool not equal operator", NotEqual, "true", "false", true}, + + // Integer parsing and comparison + {"parse int equal", Equal, "42", "42", true}, + {"parse int not equal", Equal, "42", "43", false}, + {"parse int greater than", GreaterThan, "43", "42", true}, + {"parse int less than", LessThan, "42", "43", true}, + + // Float parsing and comparison + {"parse float equal", Equal, "42.5", "42.5", true}, + {"parse float not equal", Equal, "42.5", "42.6", false}, + {"parse float greater than", GreaterThan, "42.6", "42.5", true}, + + // String comparison (when parsing fails) + {"string equal", Equal, "hello", "hello", true}, + {"string not equal", Equal, "hello", "world", false}, + {"string contains", Contains, "hello world", "world", true}, + {"string not contains", NotContains, "hello world", "xyz", true}, + + // Mixed type parsing (should fall back to string) + {"mixed types", Equal, "42", "hello", false}, + {"mixed bool and int", Equal, "true", "1", true}, // Both parse as bool: true == true + + // Semver comparison + {"semver equal", Equal, "1.2.3", "1.2.3:semver", true}, + {"semver greater than", GreaterThan, "1.2.4", "1.2.3:semver", true}, + {"semver less than", LessThan, "1.2.2", "1.2.3:semver", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseAndMatch(tt.operator, tt.traitValue, tt.conditionValue) + if result != tt.expected { + t.Errorf("parseAndMatch(%v, %q, %q) = %v, want %v", tt.operator, tt.traitValue, tt.conditionValue, result, tt.expected) + } + }) + } +} From a9d0e51317c8ea410de7649eea9387331a5352dd Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 6 Oct 2025 12:18:30 +0530 Subject: [PATCH 30/56] Short-Circuit conditions --- flagengine/engine_eval/evaluator.go | 30 ++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index 2ae05bca..38b67926 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -25,18 +25,34 @@ func IsContextInSegment(ec *EngineEvaluationContext, segmentContext *SegmentCont func contextMatchesSegmentRule(ec *EngineEvaluationContext, segmentRule *SegmentRule, segmentKey string) bool { matchesConditions := true + if len(segmentRule.Conditions) > 0 { - conditions := make([]bool, len(segmentRule.Conditions)) - for i := range segmentRule.Conditions { - conditions[i] = contextMatchesCondition(ec, &segmentRule.Conditions[i], segmentKey) - } switch segmentRule.Type { case All: - matchesConditions = utils.All(conditions) + // Short-circuit on first false + for i := range segmentRule.Conditions { + if !contextMatchesCondition(ec, &segmentRule.Conditions[i], segmentKey) { + matchesConditions = false + break + } + } case Any: - matchesConditions = utils.Any(conditions) + // Short-circuit on first true + matchesConditions = false + for i := range segmentRule.Conditions { + if contextMatchesCondition(ec, &segmentRule.Conditions[i], segmentKey) { + matchesConditions = true + break + } + } case None: - matchesConditions = utils.None(conditions) + // Short-circuit on first true + for i := range segmentRule.Conditions { + if contextMatchesCondition(ec, &segmentRule.Conditions[i], segmentKey) { + matchesConditions = false + break + } + } default: return false } From 9ca2100b5a3ef58e1e7d8d6afaa55c1f5b9ebd48 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 6 Oct 2025 15:49:54 +0530 Subject: [PATCH 31/56] review change --- flagengine/engine.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flagengine/engine.go b/flagengine/engine.go index d794520f..44690e10 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -19,7 +19,7 @@ func getPriorityOrDefault(priority *float64) float64 { if priority != nil { return *priority } - return math.Inf(1) + return math.Inf(1) // Weakest possible priority } func processSegments(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.SegmentResult, map[string]featureContextWithSegmentName) { From f353451da27cbfb3404f98698813a8d7874839e1 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 7 Oct 2025 08:22:38 +0530 Subject: [PATCH 32/56] use new integration tests --- flagengine/engine-test-data | 2 +- flagengine/flagengine_integration_test.go | 51 ++++++++++++++--------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/flagengine/engine-test-data b/flagengine/engine-test-data index facf33a4..f65dea86 160000 --- a/flagengine/engine-test-data +++ b/flagengine/engine-test-data @@ -1 +1 @@ -Subproject commit facf33a4c50fdabdce29899b19b9ea65ea70eb18 +Subproject commit f65dea86571912523fc3efb8d01af273abd744b1 diff --git a/flagengine/flagengine_integration_test.go b/flagengine/flagengine_integration_test.go index b1418e1c..91c1564e 100644 --- a/flagengine/flagengine_integration_test.go +++ b/flagengine/flagengine_integration_test.go @@ -3,7 +3,8 @@ package flagengine_test import ( "encoding/json" "os" - "strconv" + "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -11,35 +12,45 @@ import ( "github.com/Flagsmith/flagsmith-go-client/v5/flagengine" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/environments" ) -const TestData = "./engine-test-data/data/environment_n9fbf9h3v4fFgH3U3ngWhb.json" +const TestDataDir = "./engine-test-data/test_cases" func TestEngine(t *testing.T) { t.Parallel() - var testData struct { - Environment environments.EnvironmentModel `json:"environment"` - TestCases []struct { - EvaluationContext engine_eval.EngineEvaluationContext `json:"context"` - EvaluationResult engine_eval.EvaluationResult `json:"result"` - } `json:"test_cases"` - } - testSpec, err := os.ReadFile(TestData) + // Read all test case files from the test_cases directory + files, err := filepath.Glob(filepath.Join(TestDataDir, "*.json")) require.NoError(t, err) - require.NotEmpty(t, testSpec) + require.NotEmpty(t, files, "No test case files found in %s", TestDataDir) - err = json.Unmarshal(testSpec, &testData) - require.NoError(t, err) + for _, testFile := range files { + testFile := testFile // Capture range variable + testName := strings.TrimSuffix(filepath.Base(testFile), ".json") + + t.Run(testName, func(t *testing.T) { + t.Parallel() + + // Read the test case file + testSpec, err := os.ReadFile(testFile) + require.NoError(t, err) + require.NotEmpty(t, testSpec) + + // Parse the test case + var testCase struct { + Context engine_eval.EngineEvaluationContext `json:"context"` + Result engine_eval.EvaluationResult `json:"result"` + } + + err = json.Unmarshal(testSpec, &testCase) + require.NoError(t, err) - for i, c := range testData.TestCases { - t.Run(strconv.Itoa(i), func(t *testing.T) { - assert := assert.New(t) - actual := flagengine.GetEvaluationResult(&c.EvaluationContext) - expected := c.EvaluationResult + // Run the evaluation + actual := flagengine.GetEvaluationResult(&testCase.Context) + expected := testCase.Result - assert.Equal(expected.Flags, actual.Flags) + // Compare the results + assert.Equal(t, expected.Flags, actual.Flags) }) } } From a527b8248b9b456b53478755aefbf71826af4505 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 7 Oct 2025 10:47:01 +0530 Subject: [PATCH 33/56] fix percentage split operator --- flagengine/engine_eval/evaluator.go | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index 38b67926..223adcdf 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -70,19 +70,11 @@ func contextMatchesSegmentRule(ec *EngineEvaluationContext, segmentRule *Segment return true } -// matchPercentageSplit handles the PercentageSplit operator for segment conditions. func matchPercentageSplit(ec *EngineEvaluationContext, segmentCondition *Condition, segmentKey string, contextValue ContextValue) bool { var objectIds []string if contextValue != nil { - // Try to get string representation of the context value - var strValue string - switch v := contextValue.(type) { - case string: - strValue = v - default: - return false - } + strValue := ToString(contextValue) objectIds = []string{segmentKey, strValue} } else if ec.Identity != nil { objectIds = []string{segmentKey, ec.Identity.Key} @@ -91,7 +83,10 @@ func matchPercentageSplit(ec *EngineEvaluationContext, segmentCondition *Conditi } if segmentCondition.Value != nil && segmentCondition.Value.String != nil { - floatValue, _ := strconv.ParseFloat(*segmentCondition.Value.String, 64) + floatValue, err := strconv.ParseFloat(*segmentCondition.Value.String, 64) + if err != nil { + return false + } return utils.GetHashedPercentageForObjectIds(objectIds, 1) <= floatValue } return false From c9839115a9b3161f0b98624c787820579f98a589 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 7 Oct 2025 14:37:54 +0530 Subject: [PATCH 34/56] rename some functions --- flagengine/engine.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/flagengine/engine.go b/flagengine/engine.go index 44690e10..8f4db13d 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -8,13 +8,11 @@ import ( "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) -// featureContextWithSegmentName holds a feature context along with the segment name it came from. type featureContextWithSegmentName struct { featureContext *engine_eval.FeatureContext segmentName string } -// getPriorityOrDefault returns the priority value if it exists, otherwise returns the default priority. func getPriorityOrDefault(priority *float64) float64 { if priority != nil { return *priority @@ -22,7 +20,7 @@ func getPriorityOrDefault(priority *float64) float64 { return math.Inf(1) // Weakest possible priority } -func processSegments(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.SegmentResult, map[string]featureContextWithSegmentName) { +func getMatchingSegmentsAndOverrides(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.SegmentResult, map[string]featureContextWithSegmentName) { segments := []engine_eval.SegmentResult{} segmentFeatureContexts := make(map[string]featureContextWithSegmentName) @@ -70,7 +68,7 @@ func processSegments(ec *engine_eval.EngineEvaluationContext) ([]engine_eval.Seg return segments, segmentFeatureContexts } -func processFeatures(ec *engine_eval.EngineEvaluationContext, segmentFeatureContexts map[string]featureContextWithSegmentName) map[string]*engine_eval.FlagResult { +func getFlagResults(ec *engine_eval.EngineEvaluationContext, segmentFeatureContexts map[string]featureContextWithSegmentName) map[string]*engine_eval.FlagResult { flags := make(map[string]*engine_eval.FlagResult) // Get identity key if identity exists @@ -108,10 +106,10 @@ func processFeatures(ec *engine_eval.EngineEvaluationContext, segmentFeatureCont // GetEvaluationResult computes flags and matched segments. func GetEvaluationResult(ec *engine_eval.EngineEvaluationContext) engine_eval.EvaluationResult { // Process segments - segments, segmentFeatureContexts := processSegments(ec) + segments, segmentFeatureContexts := getMatchingSegmentsAndOverrides(ec) - // Process features - flags := processFeatures(ec, segmentFeatureContexts) + // Get flag results + flags := getFlagResults(ec, segmentFeatureContexts) return engine_eval.EvaluationResult{ Flags: flags, From d40556c1dab97ae1898f0a91a12b029c593c4c86 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 7 Oct 2025 14:55:55 +0530 Subject: [PATCH 35/56] cleanup/refac --- flagengine/engine.go | 4 +--- models_test.go | 16 ---------------- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/flagengine/engine.go b/flagengine/engine.go index 8f4db13d..9a674f71 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -42,14 +42,13 @@ func getMatchingSegmentsAndOverrides(ec *engine_eval.EngineEvaluationContext) ([ override := &segmentContext.Overrides[i] featureKey := override.FeatureKey - overridePriority := getPriorityOrDefault(override.Priority) - // Check if we should update the segment feature context shouldUpdate := false if existing, exists := segmentFeatureContexts[featureKey]; !exists { shouldUpdate = true } else { existingPriority := getPriorityOrDefault(existing.featureContext.Priority) + overridePriority := getPriorityOrDefault(override.Priority) if overridePriority < existingPriority { shouldUpdate = true } @@ -77,7 +76,6 @@ func getFlagResults(ec *engine_eval.EngineEvaluationContext, segmentFeatureConte identityKey = &ec.Identity.Key } - // Process features if ec.Features != nil { for _, featureContext := range ec.Features { // Check if we have a segment override for this feature diff --git a/models_test.go b/models_test.go index ea57ed33..d5044850 100644 --- a/models_test.go +++ b/models_test.go @@ -76,22 +76,6 @@ func TestMakeFlagFromEngineEvaluationFlagResult(t *testing.T) { FeatureName: "nil_feature", }, }, - { - name: "flag with empty value struct", - input: &engine_eval.FlagResult{ - Enabled: false, - FeatureKey: "empty_feature_key", - Name: "empty_feature", - Value: nil, - }, - expected: Flag{ - Enabled: false, - Value: nil, - IsDefault: false, - FeatureID: 0, - FeatureName: "empty_feature", - }, - }, { name: "flag with zero values", input: &engine_eval.FlagResult{ From 02ec949e680435987a6dd1c5ef30bfd0017af5cf Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 7 Oct 2025 17:28:25 +0530 Subject: [PATCH 36/56] feat: Add support for .jsonc test files with comments using hujson - Added github.com/tailscale/hujson dependency for parsing JSON with comments - Updated integration tests to discover and process both .json and .jsonc files - JSONC files are automatically standardized to JSON before parsing - Improved test file name extraction to handle multiple extensions --- flagengine/flagengine_integration_test.go | 23 ++++++++++++++++++++--- go.mod | 1 + go.sum | 4 ++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/flagengine/flagengine_integration_test.go b/flagengine/flagengine_integration_test.go index 91c1564e..6df01931 100644 --- a/flagengine/flagengine_integration_test.go +++ b/flagengine/flagengine_integration_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tailscale/hujson" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" @@ -19,14 +20,22 @@ const TestDataDir = "./engine-test-data/test_cases" func TestEngine(t *testing.T) { t.Parallel() - // Read all test case files from the test_cases directory - files, err := filepath.Glob(filepath.Join(TestDataDir, "*.json")) + // Read all test case files from the test_cases directory (both .json and .jsonc) + jsonFiles, err := filepath.Glob(filepath.Join(TestDataDir, "*.json")) require.NoError(t, err) + + jsoncFiles, err := filepath.Glob(filepath.Join(TestDataDir, "*.jsonc")) + require.NoError(t, err) + + files := append(jsonFiles, jsoncFiles...) require.NotEmpty(t, files, "No test case files found in %s", TestDataDir) for _, testFile := range files { testFile := testFile // Capture range variable - testName := strings.TrimSuffix(filepath.Base(testFile), ".json") + + // Get test name by removing extension + testName := filepath.Base(testFile) + testName = strings.TrimSuffix(testName, filepath.Ext(testName)) t.Run(testName, func(t *testing.T) { t.Parallel() @@ -36,6 +45,14 @@ func TestEngine(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, testSpec) + // Standardise .jsonc files to standard JSON + if strings.HasSuffix(testFile, ".jsonc") { + ast, err := hujson.Parse(testSpec) + require.NoError(t, err) + ast.Standardize() //nolint:misspell // hujson uses American spelling + testSpec = ast.Pack() + } + // Parse the test case var testCase struct { Context engine_eval.EngineEvaluationContext `json:"context"` diff --git a/go.mod b/go.mod index 268f02a5..8ce5c231 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/go-resty/resty/v2 v2.16.5 github.com/itlightning/dateparse v0.2.1 github.com/ohler55/ojg v1.26.10 + github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a ) require ( diff --git a/go.sum b/go.sum index c3582f2c..0c7c2fa2 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/itlightning/dateparse v0.2.1 h1:AB0NJTyI0HYcerEUMovKZOiQVBg1mBPxgAnWQwzLP6g= @@ -14,6 +16,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I= +github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= From 5012e6ebad2672b046d9aff43d60ff49cd31ff07 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Tue, 7 Oct 2025 17:32:14 +0530 Subject: [PATCH 37/56] fix: Configure misspell linter --- .golangci.yml | 2 ++ flagengine/flagengine_integration_test.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.golangci.yml b/.golangci.yml index 0f388ec2..2115fea3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,8 @@ linters-settings: misspell: locale: UK + ignore-words: + - standardize # hujson library uses US spelling linters: enable: - contextcheck diff --git a/flagengine/flagengine_integration_test.go b/flagengine/flagengine_integration_test.go index 6df01931..b53b8b73 100644 --- a/flagengine/flagengine_integration_test.go +++ b/flagengine/flagengine_integration_test.go @@ -49,7 +49,7 @@ func TestEngine(t *testing.T) { if strings.HasSuffix(testFile, ".jsonc") { ast, err := hujson.Parse(testSpec) require.NoError(t, err) - ast.Standardize() //nolint:misspell // hujson uses American spelling + ast.Standardize() testSpec = ast.Pack() } From 04d49515f9a4acd53dbc57defc8ab926369fc4f1 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 8 Oct 2025 08:17:23 +0530 Subject: [PATCH 38/56] make matching condition more dry --- flagengine/engine_eval/evaluator.go | 49 +++++++++++++---------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index 223adcdf..adcb6a8c 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -23,43 +23,38 @@ func IsContextInSegment(ec *EngineEvaluationContext, segmentContext *SegmentCont return true } -func contextMatchesSegmentRule(ec *EngineEvaluationContext, segmentRule *SegmentRule, segmentKey string) bool { - matchesConditions := true +// Returns true if conditions match according to the rule type. +func matchesConditionsByRuleType(ec *EngineEvaluationContext, conditions []Condition, ruleType Type, segmentKey string) bool { + for i := range conditions { + conditionMatches := contextMatchesCondition(ec, &conditions[i], segmentKey) - if len(segmentRule.Conditions) > 0 { - switch segmentRule.Type { + switch ruleType { case All: - // Short-circuit on first false - for i := range segmentRule.Conditions { - if !contextMatchesCondition(ec, &segmentRule.Conditions[i], segmentKey) { - matchesConditions = false - break - } - } - case Any: - // Short-circuit on first true - matchesConditions = false - for i := range segmentRule.Conditions { - if contextMatchesCondition(ec, &segmentRule.Conditions[i], segmentKey) { - matchesConditions = true - break - } + if !conditionMatches { + return false // Short-circuit: ALL requires all conditions to match } case None: - // Short-circuit on first true - for i := range segmentRule.Conditions { - if contextMatchesCondition(ec, &segmentRule.Conditions[i], segmentKey) { - matchesConditions = false - break - } + if conditionMatches { + return false // Short-circuit: NONE requires no conditions to match + } + case Any: + if conditionMatches { + return true // Short-circuit: ANY requires at least one condition to match } default: return false } } - if !matchesConditions { - return false + // If we reach here: ALL/NONE passed all checks, ANY found no matches + return ruleType != Any +} + +func contextMatchesSegmentRule(ec *EngineEvaluationContext, segmentRule *SegmentRule, segmentKey string) bool { + if len(segmentRule.Conditions) > 0 { + if !matchesConditionsByRuleType(ec, segmentRule.Conditions, segmentRule.Type, segmentKey) { + return false + } } for i := range segmentRule.Rules { From 8aa33b374ee743f6566a9633462ce99f5ddcf1ed Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 8 Oct 2025 08:19:39 +0530 Subject: [PATCH 39/56] bump engine test data --- flagengine/engine-test-data | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flagengine/engine-test-data b/flagengine/engine-test-data index f65dea86..f32e8eeb 160000 --- a/flagengine/engine-test-data +++ b/flagengine/engine-test-data @@ -1 +1 @@ -Subproject commit f65dea86571912523fc3efb8d01af273abd744b1 +Subproject commit f32e8eeb2fc7a08bc9d1e8a18c3e4c241f0ce81f From dec254a08299ad52d59f8189680218f2b11f6b39 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 9 Oct 2025 10:39:35 +0530 Subject: [PATCH 40/56] move trait to it's own package to avoid circular import --- flagengine/engine_eval/mappers.go | 32 ++++--------------------------- models.go | 20 +++++-------------- trait/trait.go | 22 +++++++++++++++++++++ 3 files changed, 31 insertions(+), 43 deletions(-) create mode 100644 trait/trait.go diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index 68495fd5..80f80970 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -3,7 +3,6 @@ package engine_eval import ( "crypto/sha256" "encoding/hex" - "encoding/json" "fmt" "math" "sort" @@ -14,6 +13,7 @@ import ( "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/segments" + "github.com/Flagsmith/flagsmith-go-client/v5/trait" ) // MapEnvironmentDocumentToEvaluationContext maps an environment document model @@ -288,12 +288,7 @@ func mapIdentityOverridesToSegments(identityOverrides []*identities.IdentityMode return segmentContexts } -// Trait represents a trait with key-value pair, compatible with the main package Trait struct. -type Trait struct { - TraitKey string `json:"trait_key"` - TraitValue interface{} `json:"trait_value"` - Transient bool `json:"transient,omitempty"` -} +type Trait = trait.Trait // MapContextAndIdentityDataToContext maps context and identity data to create an evaluation context // with identity information. This function takes an existing context and enriches it with identity @@ -301,34 +296,15 @@ type Trait struct { func MapContextAndIdentityDataToContext( context EngineEvaluationContext, identifier string, - traits interface{}, + traits []*trait.Trait, ) EngineEvaluationContext { - // Convert traits to local type - var traitList []*Trait - - if traits != nil { - // Handle different trait types by copying field values - switch v := traits.(type) { - case []*Trait: - traitList = v - default: - // Try to extract traits using reflection-like approach - // Since both Trait structs have the same JSON tags, we can marshal/unmarshal - if jsonBytes, err := json.Marshal(traits); err == nil { - if err := json.Unmarshal(jsonBytes, &traitList); err != nil { - // Log error or handle gracefully - for now, continue with empty list - traitList = nil - } - } - } - } // Create a copy of the context newContext := context // Create traits map for the identity identityTraits := make(map[string]any) - for _, trait := range traitList { + for _, trait := range traits { if trait == nil { continue } diff --git a/models.go b/models.go index e2c3890b..357cfdac 100644 --- a/models.go +++ b/models.go @@ -6,7 +6,7 @@ import ( "strconv" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" - "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities/traits" + "github.com/Flagsmith/flagsmith-go-client/v5/trait" ) type Flag struct { @@ -17,24 +17,14 @@ type Flag struct { FeatureName string } -type Trait struct { - TraitKey string `json:"trait_key"` - TraitValue interface{} `json:"trait_value"` - Transient bool `json:"transient,omitempty"` -} +type Trait = trait.Trait type IdentityTraits struct { - Identifier string `json:"identifier"` - Traits []*Trait `json:"traits"` - Transient bool `json:"transient,omitempty"` + Identifier string `json:"identifier"` + Traits []*trait.Trait `json:"traits"` + Transient bool `json:"transient,omitempty"` } -func (t *Trait) ToTraitModel() *traits.TraitModel { - return &traits.TraitModel{ - TraitKey: t.TraitKey, - TraitValue: fmt.Sprint(t.TraitValue), - } -} func makeFlagFromEngineEvaluationFlagResult(flagResult *engine_eval.FlagResult) Flag { value := flagResult.Value diff --git a/trait/trait.go b/trait/trait.go new file mode 100644 index 00000000..3165f6ea --- /dev/null +++ b/trait/trait.go @@ -0,0 +1,22 @@ +package trait + +import ( + "fmt" + + "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/identities/traits" +) + +// Trait represents a trait with key-value pair. +type Trait struct { + TraitKey string `json:"trait_key"` + TraitValue interface{} `json:"trait_value"` + Transient bool `json:"transient,omitempty"` +} + +// ToTraitModel converts a Trait to a TraitModel. +func (t *Trait) ToTraitModel() *traits.TraitModel { + return &traits.TraitModel{ + TraitKey: t.TraitKey, + TraitValue: fmt.Sprint(t.TraitValue), + } +} From 49f7fcd1797c4b62a229a4e3d9c66fd2a37dd33b Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Fri, 10 Oct 2025 11:31:22 +0530 Subject: [PATCH 41/56] clean mappers --- flagengine/engine_eval/mappers.go | 44 +++++++------------------- flagengine/engine_eval/mappers_test.go | 3 -- flagengine/segments/const.go | 2 +- 3 files changed, 13 insertions(+), 36 deletions(-) diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index 80f80970..d552c583 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -25,10 +25,7 @@ func MapEnvironmentDocumentToEvaluationContext(env *environments.EnvironmentMode // map environment -> EnvironmentContext ctx.Environment = EnvironmentContext{ Key: env.APIKey, - Name: env.APIKey, // Default to APIKey, will be overridden below if project exists - } - if env.Project != nil { - ctx.Environment.Name = env.Project.Name + Name: env.APIKey, } // Features (environment defaults) @@ -119,10 +116,8 @@ func mapSegmentToSegmentContext(s *segments.SegmentModel) SegmentContext { } // Overrides - if len(s.FeatureStates) > 0 { - for _, fs := range s.FeatureStates { - sc.Overrides = append(sc.Overrides, mapFeatureStateToFeatureContext(fs)) - } + for _, fs := range s.FeatureStates { + sc.Overrides = append(sc.Overrides, mapFeatureStateToFeatureContext(fs)) } // Rules @@ -136,20 +131,16 @@ func mapSegmentToSegmentContext(s *segments.SegmentModel) SegmentContext { func mapSegmentRuleToRule(r *segments.SegmentRuleModel) SegmentRule { er := SegmentRule{Type: mapRuleType(r.Type)} // Conditions - if len(r.Conditions) > 0 { - for _, c := range r.Conditions { - er.Conditions = append(er.Conditions, Condition{ - Operator: mapConditionOperator(c.Operator), - Property: c.Property, - Value: &ValueUnion{String: &c.Value}, - }) - } + for _, c := range r.Conditions { + er.Conditions = append(er.Conditions, Condition{ + Operator: Operator(c.Operator), + Property: c.Property, + Value: &ValueUnion{String: &c.Value}, + }) } // Nested rules - if len(r.Rules) > 0 { - for _, sr := range r.Rules { - er.Rules = append(er.Rules, mapSegmentRuleToRule(sr)) - } + for _, sr := range r.Rules { + er.Rules = append(er.Rules, mapSegmentRuleToRule(sr)) } return er } @@ -165,14 +156,6 @@ func mapRuleType(t segments.RuleType) Type { } } -func mapConditionOperator(op segments.ConditionOperator) Operator { - // Normalise NOT EQUAL -> NOT_EQUAL - if op == "NOT EQUAL" { - return NotEqual - } - return Operator(op) -} - // overridesKey represents a unique set of feature overrides for grouping identities. type overridesKey struct { featureKey string @@ -309,10 +292,7 @@ func MapContextAndIdentityDataToContext( continue } - // Store trait value directly as any - if trait.TraitValue != nil { - identityTraits[trait.TraitKey] = trait.TraitValue - } + identityTraits[trait.TraitKey] = trait.TraitValue } // Create the identity context diff --git a/flagengine/engine_eval/mappers_test.go b/flagengine/engine_eval/mappers_test.go index a07652fb..3f1a2dc6 100644 --- a/flagengine/engine_eval/mappers_test.go +++ b/flagengine/engine_eval/mappers_test.go @@ -80,9 +80,6 @@ func TestMapEnvironmentDocumentToEvaluationContext(t *testing.T) { if result.Environment.Key != "test-api-key" { t.Errorf("Expected Environment.Key to be 'test-api-key', got %v", result.Environment.Key) } - if result.Environment.Name != "Test Project" { - t.Errorf("Expected Environment.Name to be 'Test Project', got %v", result.Environment.Name) - } // Test Features mapping if len(result.Features) != 2 { diff --git a/flagengine/segments/const.go b/flagengine/segments/const.go index 7f6de949..8c96eb26 100644 --- a/flagengine/segments/const.go +++ b/flagengine/segments/const.go @@ -15,7 +15,7 @@ const ( Contains ConditionOperator = "CONTAINS" GreaterThanInclusive ConditionOperator = "GREATER_THAN_INCLUSIVE" NotContains ConditionOperator = "NOT_CONTAINS" - NotEqual ConditionOperator = "NOT EQUAL" + NotEqual ConditionOperator = "NOT_EQUAL" Regex ConditionOperator = "REGEX" PercentageSplit ConditionOperator = "PERCENTAGE_SPLIT" IsSet ConditionOperator = "IS_SET" From 634ceda72c0afdcac91c266504411ddd7bfea3d5 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 13 Oct 2025 13:06:52 +0530 Subject: [PATCH 42/56] use segment metadata to filter identity segments --- flagengine/engine.go | 5 ++-- flagengine/engine_eval/context.go | 19 +++++++++++++ flagengine/engine_eval/evaluator_test.go | 15 ++++++++-- flagengine/engine_eval/mappers.go | 21 +++++++++----- flagengine/engine_eval/mappers_test.go | 36 +++++++++++++++--------- flagengine/engine_eval/result.go | 2 ++ 6 files changed, 73 insertions(+), 25 deletions(-) diff --git a/flagengine/engine.go b/flagengine/engine.go index 9a674f71..871281c2 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -32,8 +32,9 @@ func getMatchingSegmentsAndOverrides(ec *engine_eval.EngineEvaluationContext) ([ // Add segment to results segments = append(segments, engine_eval.SegmentResult{ - Key: segmentContext.Key, - Name: segmentContext.Name, + Key: segmentContext.Key, + Name: segmentContext.Name, + Metadata: segmentContext.Metadata, }) // Process segment overrides diff --git a/flagengine/engine_eval/context.go b/flagengine/engine_eval/context.go index d22a1e09..b6303c3d 100644 --- a/flagengine/engine_eval/context.go +++ b/flagengine/engine_eval/context.go @@ -99,12 +99,31 @@ type IdentityContext struct { Traits map[string]any `json:"traits,omitempty"` } +// SegmentSource represents the source/origin of a segment. +type SegmentSource string + +const ( + // SegmentSourceAPI indicates the segment came from the Flagsmith API. + SegmentSourceAPI SegmentSource = "api" + // SegmentSourceIdentityOverride indicates the segment was created from identity overrides. + SegmentSourceIdentityOverride SegmentSource = "identity_override" +) + +// SegmentMetadata contains metadata information about a segment. +type SegmentMetadata struct { + SegmentID int `json:"segment_id,omitempty"` + // Source of the segment. + Source SegmentSource `json:"source,omitempty"` +} + // Represents a segment context for feature flag evaluation. type SegmentContext struct { // Key used for % split segmentation. Key string `json:"key"` // The name of the segment. Name string `json:"name"` + // Metadata about the segment. + Metadata *SegmentMetadata `json:"metadata,omitempty"` // Feature overrides for the segment. Overrides []FeatureContext `json:"overrides,omitempty"` // Rules that define the segment. diff --git a/flagengine/engine_eval/evaluator_test.go b/flagengine/engine_eval/evaluator_test.go index 0e9f0a76..1916a040 100644 --- a/flagengine/engine_eval/evaluator_test.go +++ b/flagengine/engine_eval/evaluator_test.go @@ -2,6 +2,7 @@ package engine_eval_test import ( "fmt" + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -56,9 +57,19 @@ func createEvaluationContext(traits map[string]any) *engine_eval.EngineEvaluatio // Helper function to create segment context. func createSegmentContext(key, name string, rules []engine_eval.SegmentRule) *engine_eval.SegmentContext { + // Convert key to int for SegmentID, defaulting to 0 if invalid + segmentID := 0 + if id, err := strconv.Atoi(key); err == nil { + segmentID = id + } + return &engine_eval.SegmentContext{ - Key: key, - Name: name, + Key: key, + Name: name, + Metadata: &engine_eval.SegmentMetadata{ + SegmentID: segmentID, + Source: engine_eval.SegmentSourceAPI, + }, Rules: rules, } } diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index d552c583..2f908c51 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -110,8 +110,12 @@ func mapFeatureStateToFeatureContext(fs *features.FeatureStateModel) FeatureCont func mapSegmentToSegmentContext(s *segments.SegmentModel) SegmentContext { sc := SegmentContext{ - Key: strconv.Itoa(s.ID), - Name: s.Name, + Key: strconv.Itoa(s.ID), + Name: s.Name, + Metadata: &SegmentMetadata{ + SegmentID: s.ID, + Source: SegmentSourceAPI, + }, Rules: make([]SegmentRule, 0, len(s.Rules)), } @@ -232,6 +236,9 @@ func mapIdentityOverridesToSegments(identityOverrides []*identities.IdentityMode sc := SegmentContext{ Key: "", // Identity override segments never use % Split operator Name: "identity_overrides", + Metadata: &SegmentMetadata{ + Source: SegmentSourceIdentityOverride, + }, Rules: []SegmentRule{ { Type: All, @@ -311,6 +318,7 @@ func MapContextAndIdentityDataToContext( // MapEvaluationResultSegmentsToSegmentModels converts evaluation result segments // to segments.SegmentModel with only ID and Name populated. +// Only segments with API source are included (identity overrides are filtered out). func MapEvaluationResultSegmentsToSegmentModels( result *EvaluationResult, ) []*segments.SegmentModel { @@ -321,14 +329,13 @@ func MapEvaluationResultSegmentsToSegmentModels( segmentModels := make([]*segments.SegmentModel, 0, len(result.Segments)) for _, segmentResult := range result.Segments { - // Convert key to ID - id := 0 - if parsedID, err := strconv.Atoi(segmentResult.Key); err == nil { - id = parsedID + // Only include segments from API source (filter out identity overrides) + if segmentResult.Metadata == nil || segmentResult.Metadata.Source != SegmentSourceAPI { + continue } segmentModel := &segments.SegmentModel{ - ID: id, + ID: segmentResult.Metadata.SegmentID, Name: segmentResult.Name, } diff --git a/flagengine/engine_eval/mappers_test.go b/flagengine/engine_eval/mappers_test.go index 3f1a2dc6..c31a5789 100644 --- a/flagengine/engine_eval/mappers_test.go +++ b/flagengine/engine_eval/mappers_test.go @@ -522,10 +522,26 @@ func TestMapEvaluationResultSegmentsToSegmentModels(t *testing.T) { { Key: "1", Name: "test-segment", + Metadata: &SegmentMetadata{ + SegmentID: 1, + Source: SegmentSourceAPI, + }, }, { Key: "42", Name: "another-segment", + Metadata: &SegmentMetadata{ + SegmentID: 42, + Source: SegmentSourceAPI, + }, + }, + { + Key: "", + Name: "identity-override-segment", + Metadata: &SegmentMetadata{ + SegmentID: 0, + Source: SegmentSourceIdentityOverride, + }, }, }, } @@ -533,7 +549,7 @@ func TestMapEvaluationResultSegmentsToSegmentModels(t *testing.T) { // Test the mapper segmentModels := MapEvaluationResultSegmentsToSegmentModels(&result) - // Assertions + // Assertions - should only include API segments (2), not identity overrides if len(segmentModels) != 2 { t.Errorf("Expected 2 segment models, got %d", len(segmentModels)) } @@ -582,28 +598,20 @@ func TestMapEvaluationResultSegmentsToSegmentModelsEmpty(t *testing.T) { } func TestMapEvaluationResultSegmentsToSegmentModelsInvalidKey(t *testing.T) { - // Test with segment result that has invalid key (non-numeric) + // Test with segment result that has no metadata (should be filtered out) result := EvaluationResult{ Segments: []SegmentResult{ { Key: "invalid-key", - Name: "segment-with-invalid-key", + Name: "segment-without-metadata", }, }, } segmentModels := MapEvaluationResultSegmentsToSegmentModels(&result) - if len(segmentModels) != 1 { - t.Errorf("Expected 1 segment model, got %d", len(segmentModels)) - } - - segment := segmentModels[0] - if segment.ID != 0 { - t.Errorf("Expected segment ID to be 0 for invalid key, got %d", segment.ID) - } - - if segment.Name != "segment-with-invalid-key" { - t.Errorf("Expected segment name to be 'segment-with-invalid-key', got %s", segment.Name) + // Segments without metadata should be filtered out + if len(segmentModels) != 0 { + t.Errorf("Expected 0 segment models (no metadata), got %d", len(segmentModels)) } } diff --git a/flagengine/engine_eval/result.go b/flagengine/engine_eval/result.go index fb9d5c89..d00c7ff5 100644 --- a/flagengine/engine_eval/result.go +++ b/flagengine/engine_eval/result.go @@ -27,4 +27,6 @@ type SegmentResult struct { Key string `json:"key"` // Segment name. Name string `json:"name"` + // Metadata about the segment. + Metadata *SegmentMetadata `json:"metadata,omitempty"` } From 4a4cb00fbd5439560c915c9c090b7dc48cefc53f Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 13 Oct 2025 13:52:53 +0530 Subject: [PATCH 43/56] use v2.1.0 tag --- flagengine/engine-test-data | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flagengine/engine-test-data b/flagengine/engine-test-data index f32e8eeb..37606e44 160000 --- a/flagengine/engine-test-data +++ b/flagengine/engine-test-data @@ -1 +1 @@ -Subproject commit f32e8eeb2fc7a08bc9d1e8a18c3e4c241f0ce81f +Subproject commit 37606e4437d1bd0ee6d86d79828c70a46e94fc8e From edfee3e4145c3dd6f1ef2b13839018969f91517c Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 13 Oct 2025 14:44:56 +0530 Subject: [PATCH 44/56] get rid of ValueUnion --- flagengine/engine_eval/context.go | 49 +---------------- flagengine/engine_eval/evaluator.go | 42 ++++++++++----- flagengine/engine_eval/evaluator_test.go | 69 +++++++++++------------- flagengine/engine_eval/mappers.go | 4 +- flagengine/engine_eval/mappers_test.go | 8 +-- 5 files changed, 71 insertions(+), 101 deletions(-) diff --git a/flagengine/engine_eval/context.go b/flagengine/engine_eval/context.go index b6303c3d..bb49c4b0 100644 --- a/flagengine/engine_eval/context.go +++ b/flagengine/engine_eval/context.go @@ -149,9 +149,8 @@ type Condition struct { // A reference to the identity trait or value in the evaluation context. Property string `json:"property"` // The value to compare against the trait or context value. - // - // The values to compare against the trait or context value. - Value *ValueUnion `json:"value"` + // Can be a string or []string. + Value any `json:"value"` } // The operator to use for evaluating the condition. @@ -183,50 +182,6 @@ const ( None Type = "NONE" ) -// A default environment value for the feature. If the feature is multivariate, this will be -// the control value. -// -// The value of the feature. -type ValueUnion struct { - String *string - StringArray []string -} - -// UnmarshalJSON implements custom JSON unmarshaling for ValueUnion. -func (v *ValueUnion) UnmarshalJSON(data []byte) error { - // Try to unmarshal as null first - if string(data) == "null" { - return nil - } - - // Try to unmarshal as a string array - var strArray []string - if err := json.Unmarshal(data, &strArray); err == nil { - v.StringArray = strArray - return nil - } - - // Try to unmarshal as a single string - var str string - if err := json.Unmarshal(data, &str); err == nil { - v.String = &str - return nil - } - - // Try to unmarshal as a structured object - var structured struct { - String *string `json:"string"` - StringArray []string `json:"stringArray"` - } - if err := json.Unmarshal(data, &structured); err == nil { - v.String = structured.String - v.StringArray = structured.StringArray - return nil - } - - return fmt.Errorf("unable to unmarshal ValueUnion: invalid format") -} - // UnmarshalJSON implements custom JSON unmarshaling for IdentityContext. func (ic *IdentityContext) UnmarshalJSON(data []byte) error { // Use an alias to avoid recursion diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index adcb6a8c..7b035c29 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -77,12 +77,14 @@ func matchPercentageSplit(ec *EngineEvaluationContext, segmentCondition *Conditi return false } - if segmentCondition.Value != nil && segmentCondition.Value.String != nil { - floatValue, err := strconv.ParseFloat(*segmentCondition.Value.String, 64) - if err != nil { - return false + if segmentCondition.Value != nil { + if strValue, ok := segmentCondition.Value.(string); ok { + floatValue, err := strconv.ParseFloat(strValue, 64) + if err != nil { + return false + } + return utils.GetHashedPercentageForObjectIds(objectIds, 1) <= floatValue } - return utils.GetHashedPercentageForObjectIds(objectIds, 1) <= floatValue } return false } @@ -104,8 +106,10 @@ func contextMatchesCondition(ec *EngineEvaluationContext, segmentCondition *Cond if segmentCondition.Operator == IsSet { return contextValue != nil } - if contextValue != nil { - return parseAndMatch(segmentCondition.Operator, ToString(contextValue), *segmentCondition.Value.String) + if contextValue != nil && segmentCondition.Value != nil { + if strValue, ok := segmentCondition.Value.(string); ok { + return parseAndMatch(segmentCondition.Operator, ToString(contextValue), strValue) + } } return false } @@ -118,14 +122,28 @@ func matchInOperator(segmentCondition *Condition, contextValue ContextValue) boo traitValue := ToString(contextValue) - // First try to use StringArray if available - if segmentCondition.Value != nil && len(segmentCondition.Value.StringArray) > 0 { - return slices.Contains(segmentCondition.Value.StringArray, traitValue) + if segmentCondition.Value == nil { + return false + } + + // First try to use []string if available + if strArray, ok := segmentCondition.Value.([]string); ok { + return slices.Contains(strArray, traitValue) + } + + // Convert []interface{} to []string (happens during JSON unmarshaling) + if ifaceArray, ok := segmentCondition.Value.([]interface{}); ok { + for _, v := range ifaceArray { + if str, ok := v.(string); ok && str == traitValue { + return true + } + } + return false } // Fall back to comma-separated string approach - if segmentCondition.Value != nil && segmentCondition.Value.String != nil { - values := strings.Split(*segmentCondition.Value.String, ",") + if strValue, ok := segmentCondition.Value.(string); ok { + values := strings.Split(strValue, ",") return slices.Contains(values, traitValue) } diff --git a/flagengine/engine_eval/evaluator_test.go b/flagengine/engine_eval/evaluator_test.go index 1916a040..25f8ddb5 100644 --- a/flagengine/engine_eval/evaluator_test.go +++ b/flagengine/engine_eval/evaluator_test.go @@ -35,11 +35,6 @@ func doubleValue(d float64) float64 { return d } -// Helper function to create string pointer. -func stringPtr(s string) *string { - return &s -} - // Helper function to create evaluation context with traits. func createEvaluationContext(traits map[string]any) *engine_eval.EngineEvaluationContext { return &engine_eval.EngineEvaluationContext{ @@ -98,7 +93,7 @@ func TestIsContextInSegment(t *testing.T) { { Operator: engine_eval.Equal, Property: traitKey1, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + Value: traitValue1, }, }, }, @@ -117,7 +112,7 @@ func TestIsContextInSegment(t *testing.T) { { Operator: engine_eval.Equal, Property: traitKey1, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + Value: traitValue1, }, }, }, @@ -136,12 +131,12 @@ func TestIsContextInSegment(t *testing.T) { { Operator: engine_eval.Equal, Property: traitKey1, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + Value: traitValue1, }, { Operator: engine_eval.Equal, Property: traitKey2, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue2)}, + Value: traitValue2, }, }, }, @@ -161,12 +156,12 @@ func TestIsContextInSegment(t *testing.T) { { Operator: engine_eval.Equal, Property: traitKey1, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + Value: traitValue1, }, { Operator: engine_eval.Equal, Property: traitKey2, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue2)}, + Value: traitValue2, }, }, }, @@ -186,12 +181,12 @@ func TestIsContextInSegment(t *testing.T) { { Operator: engine_eval.Equal, Property: traitKey1, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + Value: traitValue1, }, { Operator: engine_eval.Equal, Property: traitKey2, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue2)}, + Value: traitValue2, }, }, }, @@ -214,12 +209,12 @@ func TestIsContextInSegment(t *testing.T) { { Operator: engine_eval.Equal, Property: traitKey1, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + Value: traitValue1, }, { Operator: engine_eval.Equal, Property: traitKey2, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue2)}, + Value: traitValue2, }, }, }, @@ -229,7 +224,7 @@ func TestIsContextInSegment(t *testing.T) { { Operator: engine_eval.Equal, Property: traitKey3, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue3)}, + Value: traitValue3, }, }, }, @@ -307,7 +302,7 @@ func TestContextMatchesCondition(t *testing.T) { condition := &engine_eval.Condition{ Operator: c.operator, Property: c.property, - Value: &engine_eval.ValueUnion{String: stringPtr(c.conditionValue)}, + Value: c.conditionValue, } var traitValue any @@ -362,7 +357,7 @@ func TestContextMatchesConditionInOperatorStringArray(t *testing.T) { condition := &engine_eval.Condition{ Operator: engine_eval.In, Property: traitKey1, - Value: &engine_eval.ValueUnion{StringArray: c.stringArray}, + Value: c.stringArray, } traitValuePtr := stringValue(c.traitValue) @@ -448,7 +443,7 @@ func TestContextMatchesConditionPercentageSplit(t *testing.T) { condition := &engine_eval.Condition{ Operator: engine_eval.PercentageSplit, Property: "", - Value: &engine_eval.ValueUnion{String: stringPtr(c.segmentSplitValue)}, + Value: c.segmentSplitValue, } evalContext := createEvaluationContext(nil) @@ -490,7 +485,7 @@ func TestGetContextValueIntegration(t *testing.T) { { Operator: engine_eval.Equal, Property: "email", - Value: &engine_eval.ValueUnion{String: stringPtr("test@example.com")}, + Value: "test@example.com", }, }, }, @@ -510,7 +505,7 @@ func TestGetContextValueIntegration(t *testing.T) { { Operator: engine_eval.Equal, Property: "$.identity.identifier", - Value: &engine_eval.ValueUnion{String: stringPtr("test-user")}, + Value: "test-user", }, }, }, @@ -530,7 +525,7 @@ func TestGetContextValueIntegration(t *testing.T) { { Operator: engine_eval.Equal, Property: "$.environment.key", - Value: &engine_eval.ValueUnion{String: stringPtr("test-env")}, + Value: "test-env", }, }, }, @@ -559,7 +554,7 @@ func TestToStringIntegration(t *testing.T) { { Operator: engine_eval.Equal, Property: "test_prop", - Value: &engine_eval.ValueUnion{String: stringPtr("test_string")}, + Value: "test_string", }, }, }, @@ -581,7 +576,7 @@ func TestToStringIntegration(t *testing.T) { { Operator: engine_eval.Equal, Property: "test_prop", - Value: &engine_eval.ValueUnion{String: stringPtr("true")}, + Value: "true", }, }, }, @@ -603,7 +598,7 @@ func TestToStringIntegration(t *testing.T) { { Operator: engine_eval.Equal, Property: "test_prop", - Value: &engine_eval.ValueUnion{String: stringPtr("123.45")}, + Value: "123.45", }, }, }, @@ -660,7 +655,7 @@ func TestSemverComparisons(t *testing.T) { condition := &engine_eval.Condition{ Operator: c.operator, Property: "version", - Value: &engine_eval.ValueUnion{String: stringPtr(c.conditionValue)}, + Value: c.conditionValue, } evalContext := createEvaluationContext(map[string]any{ @@ -692,7 +687,7 @@ func TestComplexSegmentRules(t *testing.T) { { Operator: engine_eval.Equal, Property: traitKey1, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + Value: traitValue1, }, }, Rules: []engine_eval.SegmentRule{ @@ -702,7 +697,7 @@ func TestComplexSegmentRules(t *testing.T) { { Operator: engine_eval.Equal, Property: traitKey2, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue2)}, + Value: traitValue2, }, }, }, @@ -712,7 +707,7 @@ func TestComplexSegmentRules(t *testing.T) { { Operator: engine_eval.Equal, Property: traitKey3, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue3)}, + Value: traitValue3, }, }, }, @@ -749,12 +744,12 @@ func TestComplexSegmentRules(t *testing.T) { { Operator: engine_eval.Equal, Property: traitKey1, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue1)}, + Value: traitValue1, }, { Operator: engine_eval.Equal, Property: traitKey2, - Value: &engine_eval.ValueUnion{String: stringPtr(traitValue2)}, + Value: traitValue2, }, }, }, @@ -799,7 +794,7 @@ func TestEdgeCases(t *testing.T) { { Operator: engine_eval.Equal, Property: "some_trait", - Value: &engine_eval.ValueUnion{String: stringPtr("value")}, + Value: "value", }, }, }, @@ -854,7 +849,7 @@ func TestEdgeCases(t *testing.T) { { Operator: engine_eval.PercentageSplit, Property: "", - Value: &engine_eval.ValueUnion{String: stringPtr("50")}, + Value: "50", }, }, }, @@ -896,7 +891,7 @@ func TestRegexOperator(t *testing.T) { { Operator: engine_eval.Regex, Property: "test_trait", - Value: &engine_eval.ValueUnion{String: stringPtr(c.conditionValue)}, + Value: c.conditionValue, }, }, }, @@ -944,7 +939,7 @@ func TestModuloOperator(t *testing.T) { { Operator: engine_eval.Modulo, Property: "test_trait", - Value: &engine_eval.ValueUnion{String: stringPtr(c.conditionValue)}, + Value: c.conditionValue, }, }, }, @@ -970,7 +965,7 @@ func TestMatchWithRegexOperator(t *testing.T) { { Operator: engine_eval.Regex, Property: "email", - Value: &engine_eval.ValueUnion{String: stringPtr(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)}, + Value: `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`, }, }, }, @@ -994,7 +989,7 @@ func TestMatchWithModuloOperator(t *testing.T) { { Operator: engine_eval.Modulo, Property: "user_id", - Value: &engine_eval.ValueUnion{String: stringPtr("4|3")}, + Value: "4|3", }, }, }, diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index 2f908c51..fc327d7b 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -139,7 +139,7 @@ func mapSegmentRuleToRule(r *segments.SegmentRuleModel) SegmentRule { er.Conditions = append(er.Conditions, Condition{ Operator: Operator(c.Operator), Property: c.Property, - Value: &ValueUnion{String: &c.Value}, + Value: c.Value, }) } // Nested rules @@ -246,7 +246,7 @@ func mapIdentityOverridesToSegments(identityOverrides []*identities.IdentityMode { Operator: "IN", Property: "$.identity.identifier", - Value: &ValueUnion{String: func() *string { s := strings.Join(identifiers, ","); return &s }()}, + Value: strings.Join(identifiers, ","), }, }, }, diff --git a/flagengine/engine_eval/mappers_test.go b/flagengine/engine_eval/mappers_test.go index c31a5789..14955370 100644 --- a/flagengine/engine_eval/mappers_test.go +++ b/flagengine/engine_eval/mappers_test.go @@ -157,7 +157,9 @@ func TestMapEnvironmentDocumentToEvaluationContext(t *testing.T) { if condition.Property != "test_property" { t.Errorf("Expected condition property to be 'test_property', got %v", condition.Property) } - if condition.Value == nil || condition.Value.String == nil || *condition.Value.String != "test_value" { + if condition.Value == nil { + t.Error("Expected condition value to be set") + } else if strValue, ok := condition.Value.(string); !ok || strValue != "test_value" { t.Errorf("Expected condition value to be 'test_value', got %v", condition.Value) } } @@ -343,8 +345,8 @@ func TestMapEnvironmentDocumentToEvaluationContextWithIdentityOverrides(t *testi if condition.Property != "$.identity.identifier" { t.Errorf("Expected condition property to be '$.identity.identifier', got %v", condition.Property) } - if condition.Value == nil || condition.Value.String == nil { - t.Error("Expected condition value to have String") + if condition.Value == nil { + t.Error("Expected condition value to be set") } } } From 19a1d2d834ab7ca77b14fc22cee00bceed004f03 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 13 Oct 2025 18:48:19 +0530 Subject: [PATCH 45/56] compare segmes --- flagengine/engine_eval/evaluator.go | 10 +++++++++- flagengine/flagengine_integration_test.go | 11 ++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index 7b035c29..2eb94a80 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -1,6 +1,7 @@ package engine_eval import ( + "encoding/json" "fmt" "slices" "strconv" @@ -141,8 +142,15 @@ func matchInOperator(segmentCondition *Condition, contextValue ContextValue) boo return false } - // Fall back to comma-separated string approach + // Fall back to string - try JSON parsing first, then comma-separated if strValue, ok := segmentCondition.Value.(string); ok { + // Try to parse as JSON array first + var jsonArray []string + if err := json.Unmarshal([]byte(strValue), &jsonArray); err == nil { + return slices.Contains(jsonArray, traitValue) + } + + // Fall back to comma-separated string values := strings.Split(strValue, ",") return slices.Contains(values, traitValue) } diff --git a/flagengine/flagengine_integration_test.go b/flagengine/flagengine_integration_test.go index b53b8b73..e8f410d0 100644 --- a/flagengine/flagengine_integration_test.go +++ b/flagengine/flagengine_integration_test.go @@ -67,7 +67,16 @@ func TestEngine(t *testing.T) { expected := testCase.Result // Compare the results - assert.Equal(t, expected.Flags, actual.Flags) + assert.Equal(t, expected.Flags, actual.Flags, "Flags should match") + + // Compare segments - check key and name only since metadata is an implementation detail + if len(expected.Segments) > 0 { + require.Len(t, actual.Segments, len(expected.Segments), "Segment count should match") + for i, expectedSeg := range expected.Segments { + assert.Equal(t, expectedSeg.Key, actual.Segments[i].Key, "Segment key should match") + assert.Equal(t, expectedSeg.Name, actual.Segments[i].Name, "Segment name should match") + } + } }) } } From 5356ba6e682486aa3877e825b3405d854df12971 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Oct 2025 08:13:20 +0530 Subject: [PATCH 46/56] fix: handle JSONPath fallback for non-primitive values and invalid paths - Update engine-test-data to v2.3.0 for new test cases - Add isPrimitive() helper to detect non-primitive JSONPath results - Fall back to trait lookup when JSONPath returns objects/arrays - Fall back to trait lookup when JSONPath parsing fails - Fixes edge cases with trait keys that look like JSONPath (e.g., '$.identity') --- flagengine/engine-test-data | 2 +- flagengine/engine_eval/evaluator.go | 28 ++++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/flagengine/engine-test-data b/flagengine/engine-test-data index 37606e44..3d26dc53 160000 --- a/flagengine/engine-test-data +++ b/flagengine/engine-test-data @@ -1 +1 @@ -Subproject commit 37606e4437d1bd0ee6d86d79828c70a46e94fc8e +Subproject commit 3d26dc53a706880e79af28903fae454657f3be50 diff --git a/flagengine/engine_eval/evaluator.go b/flagengine/engine_eval/evaluator.go index 2eb94a80..78d4d329 100644 --- a/flagengine/engine_eval/evaluator.go +++ b/flagengine/engine_eval/evaluator.go @@ -160,8 +160,16 @@ func matchInOperator(segmentCondition *Condition, contextValue ContextValue) boo func getContextValue(ec *EngineEvaluationContext, property string) ContextValue { if strings.HasPrefix(property, "$.") { - return getContextValueGetter(property)(ec) - } else if ec.Identity != nil && ec.Identity.Traits != nil { + value := getContextValueGetter(property)(ec) + // Only use JSONPath result if it's a primitive value (not an object/array/map) + if value != nil && isPrimitive(value) { + return value + } + // If JSONPath returned non-primitive or nil, fall back to checking traits by exact key name + } + + // Check traits by property name (handles both regular traits and invalid JSONPath strings) + if ec.Identity != nil && ec.Identity.Traits != nil { value, exists := ec.Identity.Traits[property] if exists { return value @@ -170,6 +178,22 @@ func getContextValue(ec *EngineEvaluationContext, property string) ContextValue return nil } +// isPrimitive checks if a value is a primitive type (string, number, bool, nil) +// Objects, arrays, and maps are not considered primitive. +func isPrimitive(value any) bool { + if value == nil { + return true + } + switch value.(type) { + case string, bool, int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64: + return true + default: + return false + } +} + // getContextValueGetter returns a function to retrieve a value from the evaluation context // using either a JSONPath expression or returning nil if the property is not a valid JSONPath. func getContextValueGetter(property string) func(ec *EngineEvaluationContext) any { From fc18e3b6e41637bebf5125942acf1eaad3904ada Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Oct 2025 08:33:17 +0530 Subject: [PATCH 47/56] feat: add priority sorting for multivariate feature variants - Add Priority field to FeatureValue struct - Sort variants by priority before weight-based selection - Lower priority value = higher priority when variants overlap - Fixes multivariate feature flag variant priority sorting --- flagengine/engine.go | 23 ++++++++++++++++++++++- flagengine/engine_eval/context.go | 2 ++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/flagengine/engine.go b/flagengine/engine.go index 871281c2..36acb38c 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -3,6 +3,7 @@ package flagengine import ( "fmt" "math" + "sort" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" @@ -123,13 +124,16 @@ func getFlagResultFromFeatureContext(featureContext *engine_eval.FeatureContext, // Handle multivariate features if len(featureContext.Variants) > 0 && identityKey != nil && featureContext.Key != "" { + // Sort variants by priority (lower priority value = higher priority) + sortedVariants := getSortedVariantsByPriority(featureContext.Variants) + // Calculate hash percentage for the identity and feature combination objectIds := []string{featureContext.Key, *identityKey} hashPercentage := utils.GetHashedPercentageForObjectIds(objectIds, 1) // Select variant based on weighted distribution cumulativeWeight := 0.0 - for _, variant := range featureContext.Variants { + for _, variant := range sortedVariants { cumulativeWeight += variant.Weight if hashPercentage <= cumulativeWeight { value = variant.Value @@ -149,3 +153,20 @@ func getFlagResultFromFeatureContext(featureContext *engine_eval.FeatureContext, return flagResult } + +// getSortedVariantsByPriority returns a copy of variants sorted by priority (lower priority number = higher priority). +// Variants without priority are treated as having the weakest priority (placed at the end). +func getSortedVariantsByPriority(variants []engine_eval.FeatureValue) []engine_eval.FeatureValue { + // Create a copy to avoid modifying the original slice + sortedVariants := make([]engine_eval.FeatureValue, len(variants)) + copy(sortedVariants, variants) + + // Sort by priority (lower number = higher priority) + sort.SliceStable(sortedVariants, func(i, j int) bool { + pi := getPriorityOrDefault(sortedVariants[i].Priority) + pj := getPriorityOrDefault(sortedVariants[j].Priority) + return pi < pj + }) + + return sortedVariants +} diff --git a/flagengine/engine_eval/context.go b/flagengine/engine_eval/context.go index bb49c4b0..04f054f2 100644 --- a/flagengine/engine_eval/context.go +++ b/flagengine/engine_eval/context.go @@ -55,6 +55,8 @@ type FeatureValue struct { Value any `json:"value"` // The weight of the feature value variant, as a percentage number (i.e. 100.0). Weight float64 `json:"weight"` + // Priority of the feature flag variant. Lower values indicate a higher priority when multiple variants apply to the same context key. + Priority *float64 `json:"priority,omitempty"` } // FlexibleString is a type that can unmarshal from either string or number JSON values. From 6aeb5c6ce6d227932b3f844087304db06f4aa1db Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Oct 2025 14:15:07 +0530 Subject: [PATCH 48/56] Use feature value id as priority? --- flagengine/engine_eval/mappers.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index fc327d7b..357ebb81 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -68,9 +68,15 @@ func mapMultivariateFeatureStateValuesToVariants(multivariateValues []*features. variants := make([]FeatureValue, 0, len(multivariateValues)) for _, mv := range multivariateValues { + var priority *float64 + if mv.ID != nil { + p := float64(*mv.ID) + priority = &p + } variants = append(variants, FeatureValue{ - Value: mv.MultivariateFeatureOption.Value, - Weight: mv.PercentageAllocation, + Value: mv.MultivariateFeatureOption.Value, + Weight: mv.PercentageAllocation, + Priority: priority, }) } return variants From dc8c3172399d8d5a221d46fba353592322fa874b Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Oct 2025 14:22:31 +0530 Subject: [PATCH 49/56] compare segment metadata --- flagengine/flagengine_integration_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flagengine/flagengine_integration_test.go b/flagengine/flagengine_integration_test.go index e8f410d0..11a503c2 100644 --- a/flagengine/flagengine_integration_test.go +++ b/flagengine/flagengine_integration_test.go @@ -69,12 +69,13 @@ func TestEngine(t *testing.T) { // Compare the results assert.Equal(t, expected.Flags, actual.Flags, "Flags should match") - // Compare segments - check key and name only since metadata is an implementation detail + // Compare segments if len(expected.Segments) > 0 { require.Len(t, actual.Segments, len(expected.Segments), "Segment count should match") for i, expectedSeg := range expected.Segments { assert.Equal(t, expectedSeg.Key, actual.Segments[i].Key, "Segment key should match") assert.Equal(t, expectedSeg.Name, actual.Segments[i].Name, "Segment name should match") + assert.Equal(t, expectedSeg.Metadata, actual.Segments[i].Metadata, "Segment metadata should match") } } }) From e9d291840e099a0d3ecee6ed84abbd21159a3bc5 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Oct 2025 14:33:32 +0530 Subject: [PATCH 50/56] Add env name --- flagengine/engine_eval/mappers.go | 2 +- flagengine/engine_eval/mappers_test.go | 11 ++++++++--- flagengine/environments/models.go | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index 357ebb81..c67ef425 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -25,7 +25,7 @@ func MapEnvironmentDocumentToEvaluationContext(env *environments.EnvironmentMode // map environment -> EnvironmentContext ctx.Environment = EnvironmentContext{ Key: env.APIKey, - Name: env.APIKey, + Name: env.Name, } // Features (environment defaults) diff --git a/flagengine/engine_eval/mappers_test.go b/flagengine/engine_eval/mappers_test.go index 14955370..5d6bfe9a 100644 --- a/flagengine/engine_eval/mappers_test.go +++ b/flagengine/engine_eval/mappers_test.go @@ -17,6 +17,7 @@ func TestMapEnvironmentDocumentToEvaluationContext(t *testing.T) { // Create test data env := &environments.EnvironmentModel{ ID: 1, + Name: "Test Environment", APIKey: "test-api-key", Project: &projects.ProjectModel{ ID: 1, @@ -80,6 +81,9 @@ func TestMapEnvironmentDocumentToEvaluationContext(t *testing.T) { if result.Environment.Key != "test-api-key" { t.Errorf("Expected Environment.Key to be 'test-api-key', got %v", result.Environment.Key) } + if result.Environment.Name != "Test Environment" { + t.Errorf("Expected Environment.Name to be 'Test Environment', got %v", result.Environment.Name) + } // Test Features mapping if len(result.Features) != 2 { @@ -188,6 +192,7 @@ func TestMapEnvironmentDocumentToEvaluationContext(t *testing.T) { func TestMapEnvironmentDocumentToEvaluationContextWithNilProject(t *testing.T) { env := &environments.EnvironmentModel{ ID: 1, + Name: "Test Env Without Project", APIKey: "test-api-key", Project: nil, FeatureStates: []*features.FeatureStateModel{}, @@ -196,9 +201,9 @@ func TestMapEnvironmentDocumentToEvaluationContextWithNilProject(t *testing.T) { result := MapEnvironmentDocumentToEvaluationContext(env) - // When project is nil, name should default to APIKey - if result.Environment.Name != "test-api-key" { - t.Errorf("Expected Environment.Name to default to APIKey 'test-api-key', got %v", result.Environment.Name) + // Environment name should be preserved + if result.Environment.Name != "Test Env Without Project" { + t.Errorf("Expected Environment.Name to be 'Test Env Without Project', got %v", result.Environment.Name) } // Should have no segments when project is nil diff --git a/flagengine/environments/models.go b/flagengine/environments/models.go index bd5be9ac..980f6a4c 100644 --- a/flagengine/environments/models.go +++ b/flagengine/environments/models.go @@ -10,6 +10,7 @@ import ( type EnvironmentModel struct { ID int `json:"id"` + Name string `json:"name"` APIKey string `json:"api_key"` Project *projects.ProjectModel `json:"project"` FeatureStates []*features.FeatureStateModel `json:"feature_states"` From e05ab875f93f3a2f6c111ed4f611e215ef165f81 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Oct 2025 14:38:26 +0530 Subject: [PATCH 51/56] use feat/variant-priority-sorting --- flagengine/engine-test-data | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flagengine/engine-test-data b/flagengine/engine-test-data index 3d26dc53..aca26aab 160000 --- a/flagengine/engine-test-data +++ b/flagengine/engine-test-data @@ -1 +1 @@ -Subproject commit 3d26dc53a706880e79af28903fae454657f3be50 +Subproject commit aca26aab9da190ee8d007f80ca265db60ef83be5 From 89d2131162fd5dd75ba1e89c81e34d967288b1db Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Oct 2025 14:47:57 +0530 Subject: [PATCH 52/56] fix non-deterministic map iteration order --- flagengine/engine.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/flagengine/engine.go b/flagengine/engine.go index 36acb38c..faea09cd 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -25,8 +25,16 @@ func getMatchingSegmentsAndOverrides(ec *engine_eval.EngineEvaluationContext) ([ segments := []engine_eval.SegmentResult{} segmentFeatureContexts := make(map[string]featureContextWithSegmentName) - // Process segments - for _, segmentContext := range ec.Segments { + // Get sorted segment keys for deterministic ordering + segmentKeys := make([]string, 0, len(ec.Segments)) + for key := range ec.Segments { + segmentKeys = append(segmentKeys, key) + } + sort.Strings(segmentKeys) + + // Process segments in sorted order + for _, key := range segmentKeys { + segmentContext := ec.Segments[key] if !engine_eval.IsContextInSegment(ec, &segmentContext) { continue } From 7a6889edfe0c5f0a83e03155c7d19dee442c89be Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Wed, 15 Oct 2025 15:58:29 +0530 Subject: [PATCH 53/56] use big int for priority --- flagengine/engine.go | 5 +-- flagengine/engine_eval/context.go | 3 +- flagengine/engine_eval/mappers.go | 7 +--- flagengine/features/models.go | 17 ++++++++ flagengine/features/models_test.go | 67 ++++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 10 deletions(-) diff --git a/flagengine/engine.go b/flagengine/engine.go index faea09cd..1bb8f006 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -171,9 +171,8 @@ func getSortedVariantsByPriority(variants []engine_eval.FeatureValue) []engine_e // Sort by priority (lower number = higher priority) sort.SliceStable(sortedVariants, func(i, j int) bool { - pi := getPriorityOrDefault(sortedVariants[i].Priority) - pj := getPriorityOrDefault(sortedVariants[j].Priority) - return pi < pj + // Use big.Int Cmp: returns -1 if i < j (i has higher priority) + return sortedVariants[i].Priority.Cmp(&sortedVariants[j].Priority) < 0 }) return sortedVariants diff --git a/flagengine/engine_eval/context.go b/flagengine/engine_eval/context.go index 04f054f2..8713613f 100644 --- a/flagengine/engine_eval/context.go +++ b/flagengine/engine_eval/context.go @@ -3,6 +3,7 @@ package engine_eval import ( "encoding/json" "fmt" + "math/big" ) // A context object containing the necessary information to evaluate Flagsmith feature flags. @@ -56,7 +57,7 @@ type FeatureValue struct { // The weight of the feature value variant, as a percentage number (i.e. 100.0). Weight float64 `json:"weight"` // Priority of the feature flag variant. Lower values indicate a higher priority when multiple variants apply to the same context key. - Priority *float64 `json:"priority,omitempty"` + Priority big.Int `json:"priority,omitempty"` } // FlexibleString is a type that can unmarshal from either string or number JSON values. diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index c67ef425..cd0ddc59 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -68,15 +68,10 @@ func mapMultivariateFeatureStateValuesToVariants(multivariateValues []*features. variants := make([]FeatureValue, 0, len(multivariateValues)) for _, mv := range multivariateValues { - var priority *float64 - if mv.ID != nil { - p := float64(*mv.ID) - priority = &p - } variants = append(variants, FeatureValue{ Value: mv.MultivariateFeatureOption.Value, Weight: mv.PercentageAllocation, - Priority: priority, + Priority: mv.Priority(), }) } return variants diff --git a/flagengine/features/models.go b/flagengine/features/models.go index 631b14d9..1392ad30 100644 --- a/flagengine/features/models.go +++ b/flagengine/features/models.go @@ -2,8 +2,10 @@ package features import ( "encoding/json" + "math/big" "sort" "strconv" + "strings" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/utils" ) @@ -71,6 +73,21 @@ func (mfsv *MultivariateFeatureStateValueModel) Key() string { } return mfsv.MVFSValueUUID } +func (mfsv *MultivariateFeatureStateValueModel) Priority() big.Int { + if mfsv.ID != nil { + return *big.NewInt(int64(*mfsv.ID)) + } + // When ID is not set, convert the UUID to a big integer for priority + if mfsv.MVFSValueUUID != "" { + // Remove hyphens from UUID and parse as hexadecimal + hexStr := strings.ReplaceAll(mfsv.MVFSValueUUID, "-", "") + if bigInt, ok := new(big.Int).SetString(hexStr, 16); ok { + return *bigInt + } + } + // Return max int64 as default (weakest priority - no priority set) + return *big.NewInt(9223372036854775807) +} func (fs *FeatureStateModel) Value(identityID string) interface{} { if identityID != "" && len(fs.MultivariateFeatureStateValues) > 0 { diff --git a/flagengine/features/models_test.go b/flagengine/features/models_test.go index 26d787d4..33e2ce78 100644 --- a/flagengine/features/models_test.go +++ b/flagengine/features/models_test.go @@ -1,6 +1,7 @@ package features_test import ( + "math/big" "testing" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/features" @@ -34,3 +35,69 @@ func TestFeatureStateIsHigherSegmentPriority(t *testing.T) { assert.True(t, featureState1.IsHigherSegmentPriority(&featureState2)) assert.False(t, featureState2.IsHigherSegmentPriority(&featureState1)) } + +func TestMultivariateFeatureStateValueModelPriorityWithID(t *testing.T) { + t.Parallel() + id := 42 + mfsv := features.MultivariateFeatureStateValueModel{ + ID: &id, + } + + priority := mfsv.Priority() + expected := *big.NewInt(42) + + assert.Equal(t, expected, priority, "Priority should equal the ID value") +} + +func TestMultivariateFeatureStateValueModelPriorityWithUUID(t *testing.T) { + t.Parallel() + uuid := "550e8400-e29b-41d4-a716-446655440000" + mfsv := features.MultivariateFeatureStateValueModel{ + MVFSValueUUID: uuid, + } + + priority := mfsv.Priority() + + // Parse the expected value from the UUID (without hyphens, as hex) + expectedBigInt := new(big.Int) + expectedBigInt.SetString("550e8400e29b41d4a716446655440000", 16) + + assert.Equal(t, *expectedBigInt, priority, "Priority should equal the UUID parsed as big int") +} + +func TestMultivariateFeatureStateValueModelPriorityIDTakesPrecedenceOverUUID(t *testing.T) { + t.Parallel() + id := 100 + uuid := "550e8400-e29b-41d4-a716-446655440000" + mfsv := features.MultivariateFeatureStateValueModel{ + ID: &id, + MVFSValueUUID: uuid, + } + + priority := mfsv.Priority() + expected := *big.NewInt(100) + + assert.Equal(t, expected, priority, "Priority should use ID when both ID and UUID are present") +} + +func TestMultivariateFeatureStateValueModelPriorityDefaultsToMaxInt64(t *testing.T) { + t.Parallel() + mfsv := features.MultivariateFeatureStateValueModel{} + + priority := mfsv.Priority() + expected := *big.NewInt(9223372036854775807) // math.MaxInt64 + + assert.Equal(t, expected, priority, "Priority should default to max int64 when neither ID nor UUID is set") +} + +func TestMultivariateFeatureStateValueModelPriorityWithInvalidUUID(t *testing.T) { + t.Parallel() + mfsv := features.MultivariateFeatureStateValueModel{ + MVFSValueUUID: "not-a-valid-uuid", + } + + priority := mfsv.Priority() + expected := *big.NewInt(9223372036854775807) // Should default to max int64 + + assert.Equal(t, expected, priority, "Priority should default to max int64 when UUID is invalid") +} From 361fc2ce717f8e5dd4bbf8b010dd0bc0a30601ae Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 16 Oct 2025 08:49:25 +0530 Subject: [PATCH 54/56] fix mapper --- flagengine/engine_eval/mappers.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index cd0ddc59..227c4b4f 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -263,11 +263,7 @@ func mapIdentityOverridesToSegments(identityOverrides []*identities.IdentityMode Name: override.featureName, Enabled: override.enabled, Priority: &priority, - } - - // Set the value if provided - if override.featureValue != "" { - featureOverride.Value = override.featureValue + Value: override.featureValue, } sc.Overrides = append(sc.Overrides, featureOverride) From 7476d435845bdddebf3efea9af057f7b06fca37e Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Thu, 16 Oct 2025 11:36:47 +0530 Subject: [PATCH 55/56] use engine-test-data v2.4.0 --- flagengine/engine-test-data | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flagengine/engine-test-data b/flagengine/engine-test-data index aca26aab..6453b039 160000 --- a/flagengine/engine-test-data +++ b/flagengine/engine-test-data @@ -1 +1 @@ -Subproject commit aca26aab9da190ee8d007f80ca265db60ef83be5 +Subproject commit 6453b0391344a4d677a97cc4a9d27a8b8e329787 From 2ec8da2a0e5cce111f3364aee99de227f3f72620 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Mon, 20 Oct 2025 11:51:06 +0530 Subject: [PATCH 56/56] implement feature metadata and bump engine-test-data --- flagengine/engine-test-data | 2 +- flagengine/engine.go | 2 ++ flagengine/engine_eval/context.go | 7 +++++++ flagengine/engine_eval/mappers.go | 17 +++++++++++++---- flagengine/engine_eval/result.go | 2 ++ models.go | 7 +++---- 6 files changed, 28 insertions(+), 9 deletions(-) diff --git a/flagengine/engine-test-data b/flagengine/engine-test-data index 6453b039..41c20214 160000 --- a/flagengine/engine-test-data +++ b/flagengine/engine-test-data @@ -1 +1 @@ -Subproject commit 6453b0391344a4d677a97cc4a9d27a8b8e329787 +Subproject commit 41c202145e375c712600e318c439456de5b221d7 diff --git a/flagengine/engine.go b/flagengine/engine.go index 1bb8f006..6b06f18d 100644 --- a/flagengine/engine.go +++ b/flagengine/engine.go @@ -99,6 +99,7 @@ func getFlagResults(ec *engine_eval.EngineEvaluationContext, segmentFeatureConte Name: fc.Name, Reason: &reason, Value: fc.Value, + Metadata: fc.Metadata, } } else { // Use default feature context @@ -157,6 +158,7 @@ func getFlagResultFromFeatureContext(featureContext *engine_eval.FeatureContext, Name: featureContext.Name, Value: value, Reason: &reason, + Metadata: featureContext.Metadata, } return flagResult diff --git a/flagengine/engine_eval/context.go b/flagengine/engine_eval/context.go index 8713613f..21d5c25a 100644 --- a/flagengine/engine_eval/context.go +++ b/flagengine/engine_eval/context.go @@ -48,6 +48,8 @@ type FeatureContext struct { // An array of environment default values associated with the feature. Contains a single // value for standard features, or multiple values for multivariate features. Variants []FeatureValue `json:"variants,omitempty"` + // Metadata about the feature. + Metadata *FeatureMetadata `json:"metadata,omitempty"` } // Represents a multivariate value for a feature flag. @@ -119,6 +121,11 @@ type SegmentMetadata struct { Source SegmentSource `json:"source,omitempty"` } +// FeatureMetadata contains metadata information about a feature. +type FeatureMetadata struct { + FeatureID int `json:"feature_id,omitempty"` +} + // Represents a segment context for feature flag evaluation. type SegmentContext struct { // Key used for % split segmentation. diff --git a/flagengine/engine_eval/mappers.go b/flagengine/engine_eval/mappers.go index 227c4b4f..9f61e718 100644 --- a/flagengine/engine_eval/mappers.go +++ b/flagengine/engine_eval/mappers.go @@ -90,6 +90,9 @@ func mapFeatureStateToFeatureContext(fs *features.FeatureStateModel) FeatureCont FeatureKey: strconv.Itoa(fs.Feature.ID), Key: key, Name: fs.Feature.Name, + Metadata: &FeatureMetadata{ + FeatureID: fs.Feature.ID, + }, } // Value @@ -163,7 +166,6 @@ func mapRuleType(t segments.RuleType) Type { // overridesKey represents a unique set of feature overrides for grouping identities. type overridesKey struct { - featureKey string featureName string enabled bool featureValue string @@ -184,7 +186,7 @@ func generateHash(overrides overridesKeyList) string { // Create a string representation of the overrides var hashInput string for _, override := range overrides { - hashInput += fmt.Sprintf("%s:%s:%t:%s;", override.featureKey, override.featureName, override.enabled, override.featureValue) + hashInput += fmt.Sprintf("%s:%t:%s;", override.featureName, override.enabled, override.featureValue) } // Generate SHA256 hash @@ -197,6 +199,7 @@ func mapIdentityOverridesToSegments(identityOverrides []*identities.IdentityMode // Map from overrides key to list of identifiers featuresToIdentifiers := make(map[string][]string) overridesKeyToList := make(map[string]overridesKeyList) + featureNameToID := make(map[string]int) for _, identityOverride := range identityOverrides { if len(identityOverride.IdentityFeatures) == 0 { @@ -211,8 +214,10 @@ func mapIdentityOverridesToSegments(identityOverrides []*identities.IdentityMode featureValue = fmt.Sprint(featureState.RawValue) } + // Store feature name to ID mapping for later lookup + featureNameToID[featureState.Feature.Name] = featureState.Feature.ID + overrides = append(overrides, overridesKey{ - featureKey: strconv.Itoa(featureState.Feature.ID), featureName: featureState.Feature.Name, enabled: featureState.Enabled, featureValue: featureValue, @@ -257,13 +262,17 @@ func mapIdentityOverridesToSegments(identityOverrides []*identities.IdentityMode // Create overrides for each feature for _, override := range overrides { priority := math.Inf(-1) // Strongest possible priority + featureID := featureNameToID[override.featureName] featureOverride := FeatureContext{ Key: "", // Identity overrides never carry multivariate options - FeatureKey: override.featureKey, + FeatureKey: strconv.Itoa(featureID), Name: override.featureName, Enabled: override.enabled, Priority: &priority, Value: override.featureValue, + Metadata: &FeatureMetadata{ + FeatureID: featureID, + }, } sc.Overrides = append(sc.Overrides, featureOverride) diff --git a/flagengine/engine_eval/result.go b/flagengine/engine_eval/result.go index d00c7ff5..cd463ad3 100644 --- a/flagengine/engine_eval/result.go +++ b/flagengine/engine_eval/result.go @@ -20,6 +20,8 @@ type FlagResult struct { Reason *string `json:"reason,omitempty"` // Feature flag value. Value any `json:"value,omitempty"` + // Metadata about the feature. + Metadata *FeatureMetadata `json:"metadata,omitempty"` } type SegmentResult struct { diff --git a/models.go b/models.go index 357cfdac..6fd10941 100644 --- a/models.go +++ b/models.go @@ -3,7 +3,6 @@ package flagsmith import ( "encoding/json" "fmt" - "strconv" "github.com/Flagsmith/flagsmith-go-client/v5/flagengine/engine_eval" "github.com/Flagsmith/flagsmith-go-client/v5/trait" @@ -28,10 +27,10 @@ type IdentityTraits struct { func makeFlagFromEngineEvaluationFlagResult(flagResult *engine_eval.FlagResult) Flag { value := flagResult.Value - // Convert FeatureKey (string ID) to integer FeatureID + // Get FeatureID from metadata featureID := 0 - if id, err := strconv.Atoi(flagResult.FeatureKey); err == nil { - featureID = id + if flagResult.Metadata != nil { + featureID = flagResult.Metadata.FeatureID } return Flag{