From a478bc62773173d3711a4f1e2121e35b2f6b6986 Mon Sep 17 00:00:00 2001
From: Marcus Cobden <52370+leth@users.noreply.github.com>
Date: Tue, 4 Nov 2025 22:47:17 +0000
Subject: [PATCH] feat: Add custom regex_match string comparison
---
core/pkg/evaluator/json.go | 1 +
core/pkg/evaluator/string_comparison.go | 135 +++++++++++++
core/pkg/evaluator/string_comparison_test.go | 188 ++++++++++++++++++
.../flag-configuration.md | 1 +
docs/reference/flag-definitions.md | 13 +-
5 files changed, 332 insertions(+), 6 deletions(-)
diff --git a/core/pkg/evaluator/json.go b/core/pkg/evaluator/json.go
index 5849bb5f3..fa08bfbfc 100644
--- a/core/pkg/evaluator/json.go
+++ b/core/pkg/evaluator/json.go
@@ -153,6 +153,7 @@ func NewResolver(store store.IStore, logger *logger.Logger, jsonEvalTracer trace
jsonlogic.AddOperator(FractionEvaluationName, NewFractional(logger).Evaluate)
jsonlogic.AddOperator(StartsWithEvaluationName, NewStringComparisonEvaluator(logger).StartsWithEvaluation)
jsonlogic.AddOperator(EndsWithEvaluationName, NewStringComparisonEvaluator(logger).EndsWithEvaluation)
+ jsonlogic.AddOperator(RegexMatchEvaluationName, NewRegexMatchEvaluator(logger, store).RegexMatchEvaluation)
jsonlogic.AddOperator(SemVerEvaluationName, NewSemVerComparison(logger).SemVerEvaluation)
return Resolver{store: store, Logger: logger, tracer: jsonEvalTracer}
diff --git a/core/pkg/evaluator/string_comparison.go b/core/pkg/evaluator/string_comparison.go
index d4a0d6b7c..1d8e3e6e0 100644
--- a/core/pkg/evaluator/string_comparison.go
+++ b/core/pkg/evaluator/string_comparison.go
@@ -1,16 +1,21 @@
package evaluator
import (
+ "context"
"errors"
"fmt"
+ "regexp"
"strings"
"github.com/open-feature/flagd/core/pkg/logger"
+ "github.com/open-feature/flagd/core/pkg/store"
+ "go.uber.org/zap"
)
const (
StartsWithEvaluationName = "starts_with"
EndsWithEvaluationName = "ends_with"
+ RegexMatchEvaluationName = "regex_match"
)
type StringComparisonEvaluator struct {
@@ -123,3 +128,133 @@ func parseStringComparisonEvaluationData(values interface{}) (string, string, er
return property, targetValue, nil
}
+
+type RegexMatchEvaluator struct {
+ Logger *logger.Logger
+ // RegexCache caches compiled regex patterns for reuse
+ RegexCache *map[string]*regexp.Regexp
+ // PrevRegexCache holds the previous cache to allow for cache retention across config reloads
+ PrevRegexCache *map[string]*regexp.Regexp
+}
+
+func NewRegexMatchEvaluator(log *logger.Logger, s store.IStore) *RegexMatchEvaluator {
+ self := &RegexMatchEvaluator{
+ Logger: log,
+ RegexCache: &map[string]*regexp.Regexp{},
+ PrevRegexCache: &map[string]*regexp.Regexp{},
+ }
+
+ watcher := make(chan store.FlagQueryResult, 1)
+ go func() {
+ for range watcher {
+ // On config change, rotate the regex caches
+ // If the current cache is empty, do nothing, to keep the previous cache intact in case it is still helpful
+ if len(*self.RegexCache) == 0 {
+ continue
+ }
+ self.PrevRegexCache = self.RegexCache
+ self.RegexCache = &map[string]*regexp.Regexp{}
+ }
+ }()
+ selector := store.NewSelector("")
+ s.Watch(context.Background(), &selector, watcher)
+
+ return self
+}
+
+// RegexMatchEvaluation checks if the given property matches a certain regex pattern.
+// It returns 'true', if the value of the given property matches the pattern, 'false' if not.
+// As an example, it can be used in the following way inside an 'if' evaluation:
+//
+// {
+// "if": [
+// {
+// "regex_match": [{"var": "email"}, ".*@faas\\.com"]
+// },
+// "red", null
+// ]
+// }
+//
+// This rule can be applied to the following data object, where the evaluation will resolve to 'true':
+//
+// { "email": "user@faas.com" }
+//
+// Note that the 'regex_match' evaluation rule must contain two or three items, all of which resolve to a
+// string value.
+// The first item is the property to check, the second item is the regex pattern to match against,
+// and an optional third item can contain regex flags (e.g. "i" for case-insensitive matching).
+func (rme *RegexMatchEvaluator) RegexMatchEvaluation(values, _ interface{}) interface{} {
+ propertyValue, pattern, flags, err := parseRegexMatchEvaluationData(values)
+ if err != nil {
+ rme.Logger.Error("error parsing regex_match evaluation data: %v", zap.Error(err))
+ return false
+ }
+
+ re, err := rme.getRegex(pattern, flags)
+ if err != nil {
+ rme.Logger.Error("error compiling regex pattern: %v", zap.Error(err))
+ return false
+ }
+
+ return re.MatchString(propertyValue)
+}
+
+var validFlagsStringRe *regexp.Regexp = regexp.MustCompile("[imsU]+")
+
+func parseRegexMatchEvaluationData(values interface{}) (string, string, string, error) {
+ parsed, ok := values.([]interface{})
+ if !ok {
+ return "", "", "", errors.New("regex_match evaluation is not an array")
+ }
+
+ if len(parsed) != 2 && len(parsed) != 3 {
+ return "", "", "", errors.New("regex_match evaluation must contain a value, a regex pattern, and (optionally) regex flags")
+ }
+
+ property, ok := parsed[0].(string)
+ if !ok {
+ return "", "", "", errors.New("regex_match evaluation: property did not resolve to a string value")
+ }
+
+ pattern, ok := parsed[1].(string)
+ if !ok {
+ return "", "", "", errors.New("regex_match evaluation: pattern did not resolve to a string value")
+ }
+
+ flags := ""
+ if (len(parsed) == 3) {
+ flags, ok = parsed[2].(string)
+ if !ok {
+ return "", "", "", errors.New("regex_match evaluation: flags did not resolve to a string value")
+ }
+ if !validFlagsStringRe.MatchString(flags) {
+ return "", "", "", errors.New("regex_match evaluation: flags value is invalid")
+ }
+ }
+
+ return property, pattern, flags, nil
+}
+
+func (rme *RegexMatchEvaluator) getRegex(pattern string, flags string) (*regexp.Regexp, error) {
+ finalPattern := pattern
+ if flags != "" {
+ finalPattern = fmt.Sprintf("(?%s)%s", flags, pattern)
+ }
+
+ if cached, exists := (*rme.RegexCache)[finalPattern]; exists {
+ return cached, nil
+ }
+
+ // Check previous cache to allow for cache retention across config reloads
+ if cached, exists := (*rme.PrevRegexCache)[finalPattern]; exists {
+ (*rme.RegexCache)[finalPattern] = cached
+ delete(*rme.PrevRegexCache, finalPattern)
+ return cached, nil
+ }
+
+ regexp, err := regexp.Compile(finalPattern)
+ if err != nil {
+ return nil, err
+ }
+ return regexp, nil
+}
\ No newline at end of file
diff --git a/core/pkg/evaluator/string_comparison_test.go b/core/pkg/evaluator/string_comparison_test.go
index f22466f02..9fd939bca 100644
--- a/core/pkg/evaluator/string_comparison_test.go
+++ b/core/pkg/evaluator/string_comparison_test.go
@@ -431,3 +431,191 @@ func Test_parseStringComparisonEvaluationData(t *testing.T) {
})
}
}
+
+func TestJSONEvaluator_regexMatchEvaluation(t *testing.T) {
+ const source = "testSource"
+ var sources = []string{source}
+ ctx := context.Background()
+
+ tests := map[string]struct {
+ flags []model.Flag
+ flagKey string
+ context map[string]any
+ expectedValue string
+ expectedVariant string
+ expectedReason string
+ expectedError error
+ }{
+ "two strings provided - match": {
+ flags: []model.Flag{{
+ Key: "headerColor",
+ State: "ENABLED",
+ DefaultVariant: "red",
+ Variants: colorVariants,
+ Targeting: []byte(`{
+ "if": [
+ {
+ "regex_match": ["user@faas.com", ".*"]
+ },
+ "red", null
+ ]
+ }`),
+ },
+ },
+ flagKey: "headerColor",
+ context: map[string]any{},
+ expectedVariant: "red",
+ expectedValue: "#FF0000",
+ expectedReason: model.TargetingMatchReason,
+ },
+ "resolve target property using nested operation - match": {
+ flags: []model.Flag{{
+ Key: "headerColor",
+ State: "ENABLED",
+ DefaultVariant: "red",
+ Variants: colorVariants,
+ Targeting: []byte(`{
+ "if": [
+ {
+ "regex_match": [{"var": "email"}, ".*@.*"]
+ },
+ "red", null
+ ]
+ }`),
+ },
+ },
+ flagKey: "headerColor",
+ context: map[string]any{
+ "email": "user@faas.com",
+ },
+ expectedVariant: "red",
+ expectedValue: "#FF0000",
+ expectedReason: model.TargetingMatchReason,
+ },
+ "two strings provided - no match": {
+ flags: []model.Flag{{
+ Key: "headerColor",
+ State: "ENABLED",
+ DefaultVariant: "red",
+ Variants: colorVariants,
+ Targeting: []byte(`{
+ "if": [
+ {
+ "regex_match": ["user@faas.com", ".*FAAS.*"]
+ },
+ "red", "green"
+ ]
+ }`),
+ },
+ },
+ flagKey: "headerColor",
+ context: map[string]any{
+ "email": "user@faas.com",
+ },
+ expectedVariant: "green",
+ expectedValue: "#00FF00",
+ expectedReason: model.TargetingMatchReason,
+ },
+ "three strings provided - match": {
+ flags: []model.Flag{{
+ Key: "headerColor",
+ State: "ENABLED",
+ DefaultVariant: "red",
+ Variants: colorVariants,
+ Targeting: []byte(`{
+ "if": [
+ {
+ "regex_match": ["user@faas.com", ".*FAAS.*", "i"]
+ },
+ "red", null
+ ]
+ }`),
+ },
+ },
+ flagKey: "headerColor",
+ context: map[string]any{},
+ expectedVariant: "red",
+ expectedValue: "#FF0000",
+ expectedReason: model.TargetingMatchReason,
+ },
+ "resolve target property using nested operation - no match": {
+ flags: []model.Flag{{
+ Key: "headerColor",
+ State: "ENABLED",
+ DefaultVariant: "red",
+ Variants: colorVariants,
+ Targeting: []byte(`{
+ "if": [
+ {
+ "regex_match": [{"var": "email"}, "nope"]
+ },
+ "red", "green"
+ ]
+ }`),
+ },
+ },
+ flagKey: "headerColor",
+ context: map[string]any{
+ "email": "user@faas.com",
+ },
+ expectedVariant: "green",
+ expectedValue: "#00FF00",
+ expectedReason: model.TargetingMatchReason,
+ },
+ "error during parsing - return default": {
+ flags: []model.Flag{{
+ Key: "headerColor",
+ State: "ENABLED",
+ DefaultVariant: "red",
+ Variants: colorVariants,
+ Targeting: []byte(`{
+ "if": [
+ {
+ "regex_match": "no-array"
+ },
+ "red", "green"
+ ]
+ }`),
+ },
+ },
+ flagKey: "headerColor",
+ context: map[string]any{
+ "email": "user@faas.com",
+ },
+ expectedVariant: "green",
+ expectedValue: "#00FF00",
+ expectedReason: model.TargetingMatchReason,
+ },
+ }
+
+ const reqID = "default"
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ log := logger.NewLogger(nil, false)
+ s, err := store.NewStore(log, sources)
+ if err != nil {
+ t.Fatalf("NewStore failed: %v", err)
+ }
+ je := NewJSON(log, s)
+ je.store.Update(source, tt.flags, model.Metadata{})
+
+ value, variant, reason, _, err := resolve[string](ctx, reqID, tt.flagKey, tt.context, je.evaluateVariant)
+
+ if value != tt.expectedValue {
+ t.Errorf("expected value '%s', got '%s'", tt.expectedValue, value)
+ }
+
+ if variant != tt.expectedVariant {
+ t.Errorf("expected variant '%s', got '%s'", tt.expectedVariant, variant)
+ }
+
+ if reason != tt.expectedReason {
+ t.Errorf("expected reason '%s', got '%s'", tt.expectedReason, reason)
+ }
+
+ if err != tt.expectedError {
+ t.Errorf("expected err '%v', got '%v'", tt.expectedError, err)
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/docs/architecture-decisions/flag-configuration.md b/docs/architecture-decisions/flag-configuration.md
index 3661ad5a8..e2c3b3956 100644
--- a/docs/architecture-decisions/flag-configuration.md
+++ b/docs/architecture-decisions/flag-configuration.md
@@ -52,6 +52,7 @@ The system provides two tiers of operators:
- `fractional`: Deterministic percentage-based distribution using murmur3 hashing
- `starts_with`/`ends_with`: String prefix/suffix matching for common patterns
+- `regex_match`: String regular expression matching
- `sem_ver`: Semantic version comparisons with standard (npm-style) operators
- `$ref`: Reference to shared evaluators for DRY principle
diff --git a/docs/reference/flag-definitions.md b/docs/reference/flag-definitions.md
index 59974c9d8..bee0de4cf 100644
--- a/docs/reference/flag-definitions.md
+++ b/docs/reference/flag-definitions.md
@@ -245,12 +245,13 @@ These are custom operations specific to flagd and flagd providers.
They are purpose-built extensions to JsonLogic in order to support common feature flag use cases.
Consistent with built-in JsonLogic operators, flagd's custom operators return falsy/nullish values with invalid inputs.
-| Function | Description | Context attribute type | Example |
-| ---------------------------------- | --------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `fractional` (_available v0.6.4+_) | Deterministic, pseudorandom fractional distribution | string (bucketing value) | Logic: `#!json { "fractional" : [ { "var": "email" }, [ "red" , 50], [ "green" , 50 ] ] }`
Result: Pseudo randomly `red` or `green` based on the evaluation context property `email`.
Additional documentation can be found [here](./custom-operations/fractional-operation.md). |
-| `starts_with` | Attribute starts with the specified value | string | Logic: `#!json { "starts_with" : [ "192.168.0.1", "192.168"] }`
Result: `true`
Logic: `#!json { "starts_with" : [ "10.0.0.1", "192.168"] }`
Result: `false`
Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). |
-| `ends_with` | Attribute ends with the specified value | string | Logic: `#!json { "ends_with" : [ "noreply@example.com", "@example.com"] }`
Result: `true`
Logic: `#!json { ends_with" : [ "noreply@example.com", "@test.com"] }`
Result: `false`
Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). |
-| `sem_ver` | Attribute matches a semantic versioning condition | string (valid [semver](https://semver.org/)) | Logic: `#!json {"sem_ver": ["1.1.2", ">=", "1.0.0"]}`
Result: `true`
Additional documentation can be found [here](./custom-operations/semver-operation.md). |
+| Function | Description | Context attribute type | Example |
+| ---------------------------------- | --------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `fractional` (_available v0.6.4+_) | Deterministic, pseudorandom fractional distribution | string (bucketing value) | Logic: `#!json { "fractional" : [ { "var": "email" }, [ "red" , 50], [ "green" , 50 ] ] }`
Result: Pseudo randomly `red` or `green` based on the evaluation context property `email`.
Additional documentation can be found [here](./custom-operations/fractional-operation.md). |
+| `starts_with` | Attribute starts with the specified value | string | Logic: `#!json { "starts_with" : [ "192.168.0.1", "192.168"] }`
Result: `true`
Logic: `#!json { "starts_with" : [ "10.0.0.1", "192.168"] }`
Result: `false`
Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). |
+| `ends_with` | Attribute ends with the specified value | string | Logic: `#!json { "ends_with" : [ "noreply@example.com", "@example.com"] }`
Result: `true`
Logic: `#!json { ends_with" : [ "noreply@example.com", "@test.com"] }`
Result: `false`
Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). |
+| `sem_ver` | Attribute matches a semantic versioning condition | string (valid [semver](https://semver.org/)) | Logic: `#!json {"sem_ver": ["1.1.2", ">=", "1.0.0"]}`
Result: `true`
Additional documentation can be found [here](./custom-operations/semver-operation.md). |
+| `regex_match` (_available TBD_) | Attribute matches the specified regular expression | string | Logic: `#!json { "regex_match" : [ "noreply@example.com", ".*@example.com"] }`
Result: `true`
Logic: `#!json { regex_match" : [ "noreply@example.com", ".*@test.com"] }`
Result: `false`
Additional documentation can be found [here](./custom-operations/string-comparison-operation.md). |
#### Targeting key