From 75b0cd8d9a4d4b120df5b15d9264a7a0e55d02cd Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Wed, 9 Jul 2025 15:54:55 -0700 Subject: [PATCH 01/20] Add builder_new.go with work-in-progress ContextBuilder type --- ldcontext/builder_new.go | 83 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 ldcontext/builder_new.go diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go new file mode 100644 index 0000000..443241d --- /dev/null +++ b/ldcontext/builder_new.go @@ -0,0 +1,83 @@ +package ldcontext + +import ( + "sort" + + "github.com/launchdarkly/go-sdk-common/v3/lderrors" +) + +type ContextBuilder struct { + singleBuilders map[string]*Builder +} + +type KindBuilder struct { + *ContextBuilder + singleBuilder *Builder +} + +func NewContextBuilder() *ContextBuilder { + return &ContextBuilder{singleBuilders: make(map[string]*Builder)} +} + +func (cb *ContextBuilder) Kind(kind string, key string) *KindBuilder { + singleBuilder, ok := cb.singleBuilders[kind] + if !ok { + singleBuilder = NewBuilder(key) + cb.singleBuilders[kind] = singleBuilder + } + singleBuilder.Key(key) + return &KindBuilder{ContextBuilder: cb, singleBuilder: singleBuilder} +} + +func (cb *ContextBuilder) Build() Context { + if len(cb.singleBuilders) == 0 { + return Context{defined: true, err: lderrors.ErrContextKindMultiWithNoKinds{}} + } + + // TODO: calling `.Build()` on a Builder does mostly what we want, but it also + // converts a "" kind to "user". This builder should instead probably enforce that you provide + // non-empty kinds. + + if len(cb.singleBuilders) == 1 { + // If only one context kind was added, the result is just the same as building that one context + for _, singleBuilder := range cb.singleBuilders { + return singleBuilder.Build() + } + panic("impossible") + } + + // Sort the list by kind - this makes our output deterministic and will also be important when we + // compute a fully qualified key. + var kinds []string + for kind := range cb.singleBuilders { + kinds = append(kinds, kind) + } + sort.Strings(kinds) + + ret := Context{ + defined: true, + kind: MultiKind, + multiContexts: make([]Context, 0, len(cb.singleBuilders)), + } + for _, kind := range kinds { + singleBuilder := cb.singleBuilders[kind] + ctx, err := singleBuilder.TryBuild() + if err != nil { + ret.err = err + return ret + } + ret.multiContexts = append(ret.multiContexts, ctx) + } + + // Fully-qualified key for multi-context is defined as "kind1:key1:kind2:key2" etc., where kinds are in + // alphabetical order (we have already sorted them above) and keys are URL-encoded. In this case we + // do _not_ omit a default kind of "user". + for _, c := range ret.multiContexts { + if ret.fullyQualifiedKey != "" { + ret.fullyQualifiedKey += ":" + } + ret.fullyQualifiedKey += makeFullyQualifiedKeySingleKind(c.kind, c.key, false) + } + + return ret +} From 2d3240e8a84ff7cc0099e22376829916d15279ba Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Wed, 9 Jul 2025 16:28:00 -0700 Subject: [PATCH 02/20] Add TryBuild, various Builder methods to KindBuilder --- ldcontext/builder_new.go | 76 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go index 443241d..d48b83a 100644 --- a/ldcontext/builder_new.go +++ b/ldcontext/builder_new.go @@ -3,7 +3,9 @@ package ldcontext import ( "sort" + "github.com/launchdarkly/go-sdk-common/v3/ldattr" "github.com/launchdarkly/go-sdk-common/v3/lderrors" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" ) type ContextBuilder struct { @@ -81,3 +83,77 @@ func (cb *ContextBuilder) Build() Context { return ret } + +func (cb *ContextBuilder) TryBuild() (Context, error) { + c := cb.Build() + return c, c.Err() +} + +func (kb *KindBuilder) Key(key string) *KindBuilder { + kb.singleBuilder.Key(key) + return kb +} + +func (kb *KindBuilder) Name(name string) *KindBuilder { + kb.singleBuilder.Name(name) + return kb +} + +func (kb *KindBuilder) OptName(name ldvalue.OptionalString) *KindBuilder { + kb.singleBuilder.OptName(name) + return kb +} + +func (kb *KindBuilder) SetBool(attributeName string, value bool) *KindBuilder { + kb.singleBuilder.SetBool(attributeName, value) + return kb +} + +func (kb *KindBuilder) SetFloat64(attributeName string, value float64) *KindBuilder { + kb.singleBuilder.SetFloat64(attributeName, value) + return kb +} + +func (kb *KindBuilder) SetInt(attributeName string, value int) *KindBuilder { + kb.singleBuilder.SetInt(attributeName, value) + return kb +} + +func (kb *KindBuilder) SetString(attributeName string, value string) *KindBuilder { + kb.singleBuilder.SetString(attributeName, value) + return kb +} + +func (kb *KindBuilder) SetValue(attributeName string, value ldvalue.Value) *KindBuilder { + kb.singleBuilder.SetValue(attributeName, value) + return kb +} + +func (kb *KindBuilder) TrySetValue(attributeName string, value ldvalue.Value) bool { + return kb.singleBuilder.TrySetValue(attributeName, value) +} + +func (kb *KindBuilder) Anonymous(value bool) *KindBuilder { + kb.singleBuilder.Anonymous(value) + return kb +} + +func (kb *KindBuilder) Private(attributeName string) *KindBuilder { + kb.singleBuilder.Private(attributeName) + return kb +} + +func (kb *KindBuilder) PrivateRef(attributeRef ldattr.Ref) *KindBuilder { + kb.singleBuilder.PrivateRef(attributeRef) + return kb +} + +func (kb *KindBuilder) RemovePrivate(attrRefStrings ...string) *KindBuilder { + kb.singleBuilder.RemovePrivate(attrRefStrings...) + return kb +} + +func (kb *KindBuilder) RemovePrivateRef(attrRefs ...ldattr.Ref) *KindBuilder { + kb.singleBuilder.RemovePrivateRef(attrRefs...) + return kb +} From d3eec3c27625d7b2ede3c24ec142ae56cfc6a0ad Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 10:02:17 -0700 Subject: [PATCH 03/20] Add unit tests, fix some bugs --- ldcontext/builder_new.go | 20 +-- ldcontext/builder_new_test.go | 285 ++++++++++++++++++++++++++++++++++ 2 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 ldcontext/builder_new_test.go diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go index d48b83a..59bb68c 100644 --- a/ldcontext/builder_new.go +++ b/ldcontext/builder_new.go @@ -9,7 +9,7 @@ import ( ) type ContextBuilder struct { - singleBuilders map[string]*Builder + singleBuilders map[Kind]*Builder } type KindBuilder struct { @@ -18,13 +18,13 @@ type KindBuilder struct { } func NewContextBuilder() *ContextBuilder { - return &ContextBuilder{singleBuilders: make(map[string]*Builder)} + return &ContextBuilder{singleBuilders: make(map[Kind]*Builder)} } -func (cb *ContextBuilder) Kind(kind string, key string) *KindBuilder { +func (cb *ContextBuilder) Kind(kind Kind, key string) *KindBuilder { singleBuilder, ok := cb.singleBuilders[kind] if !ok { - singleBuilder = NewBuilder(key) + singleBuilder = NewBuilder(key).Kind(kind) cb.singleBuilders[kind] = singleBuilder } singleBuilder.Key(key) @@ -52,7 +52,7 @@ func (cb *ContextBuilder) Build() Context { // compute a fully qualified key. var kinds []string for kind := range cb.singleBuilders { - kinds = append(kinds, kind) + kinds = append(kinds, string(kind)) } sort.Strings(kinds) @@ -62,7 +62,7 @@ func (cb *ContextBuilder) Build() Context { multiContexts: make([]Context, 0, len(cb.singleBuilders)), } for _, kind := range kinds { - singleBuilder := cb.singleBuilders[kind] + singleBuilder := cb.singleBuilders[Kind(kind)] ctx, err := singleBuilder.TryBuild() if err != nil { ret.err = err @@ -138,13 +138,13 @@ func (kb *KindBuilder) Anonymous(value bool) *KindBuilder { return kb } -func (kb *KindBuilder) Private(attributeName string) *KindBuilder { - kb.singleBuilder.Private(attributeName) +func (kb *KindBuilder) Private(attrRefStrings ...string) *KindBuilder { + kb.singleBuilder.Private(attrRefStrings...) return kb } -func (kb *KindBuilder) PrivateRef(attributeRef ldattr.Ref) *KindBuilder { - kb.singleBuilder.PrivateRef(attributeRef) +func (kb *KindBuilder) PrivateRef(attrRefs ...ldattr.Ref) *KindBuilder { + kb.singleBuilder.PrivateRef(attrRefs...) return kb } diff --git a/ldcontext/builder_new_test.go b/ldcontext/builder_new_test.go new file mode 100644 index 0000000..7dbd45b --- /dev/null +++ b/ldcontext/builder_new_test.go @@ -0,0 +1,285 @@ +package ldcontext + +import ( + "testing" + + "github.com/launchdarkly/go-sdk-common/v3/ldattr" + "github.com/launchdarkly/go-sdk-common/v3/lderrors" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + + "github.com/stretchr/testify/assert" +) + +func TestContextBuilderSingleKind(t *testing.T) { + t.Run("basic properties", func(t *testing.T) { + c := NewContextBuilder().Kind("user", "my-key").Build() + assert.True(t, c.IsDefined()) + assert.NoError(t, c.Err()) + assert.Equal(t, DefaultKind, c.Kind()) + assert.Equal(t, "my-key", c.Key()) + assert.Equal(t, ldvalue.OptionalString{}, c.Name()) + assert.False(t, c.Anonymous()) + }) + + t.Run("custom kind", func(t *testing.T) { + c := NewContextBuilder().Kind("org", "my-org-key").Build() + assert.Equal(t, Kind("org"), c.Kind()) + assert.Equal(t, "my-org-key", c.Key()) + }) + + t.Run("with name", func(t *testing.T) { + c := NewContextBuilder().Kind("user", "my-key").Name("my-name").Build() + assert.Equal(t, ldvalue.NewOptionalString("my-name"), c.Name()) + }) + + t.Run("with anonymous", func(t *testing.T) { + c := NewContextBuilder().Kind("user", "my-key").Anonymous(true).Build() + assert.True(t, c.Anonymous()) + }) + + t.Run("with custom attributes", func(t *testing.T) { + c := NewContextBuilder().Kind("user", "my-key"). + SetString("email", "test@example.com"). + SetBool("active", true). + Build() + + assert.Equal(t, ldvalue.String("test@example.com"), c.GetValue("email")) + assert.Equal(t, ldvalue.Bool(true), c.GetValue("active")) + }) + + t.Run("with private attributes", func(t *testing.T) { + c := NewContextBuilder().Kind("user", "my-key"). + Name("my-name"). + Private("name", "email"). + Build() + + assert.Equal(t, 2, c.PrivateAttributeCount()) + }) +} + +func TestContextBuilderMultiKind(t *testing.T) { + t.Run("two kinds", func(t *testing.T) { + c := NewContextBuilder(). + Kind("user", "user-key").Name("User Name"). + Kind("org", "org-key").Name("Org Name"). + Build() + + assert.True(t, c.Multiple()) + assert.Equal(t, Kind("multi"), c.Kind()) + assert.Equal(t, 2, c.IndividualContextCount()) + + userCtx := c.IndividualContextByKind("user") + assert.Equal(t, "user-key", userCtx.Key()) + assert.Equal(t, ldvalue.NewOptionalString("User Name"), userCtx.Name()) + + orgCtx := c.IndividualContextByKind("org") + assert.Equal(t, "org-key", orgCtx.Key()) + assert.Equal(t, ldvalue.NewOptionalString("Org Name"), orgCtx.Name()) + }) + + t.Run("three kinds", func(t *testing.T) { + c := NewContextBuilder(). + Kind("user", "user-key"). + Kind("org", "org-key"). + Kind("device", "device-key"). + Build() + + assert.Equal(t, 3, c.IndividualContextCount()) + assert.NotEqual(t, Context{}, c.IndividualContextByKind("user")) + assert.NotEqual(t, Context{}, c.IndividualContextByKind("org")) + assert.NotEqual(t, Context{}, c.IndividualContextByKind("device")) + }) + + t.Run("updating existing kind", func(t *testing.T) { + c := NewContextBuilder(). + Kind("user", "user-key-1").Name("Name 1"). + Kind("org", "org-key"). + Kind("user", "user-key-2").Name("Name 2"). + Build() + + assert.Equal(t, 2, c.IndividualContextCount()) + userCtx := c.IndividualContextByKind("user") + assert.Equal(t, "user-key-2", userCtx.Key()) + assert.Equal(t, ldvalue.NewOptionalString("Name 2"), userCtx.Name()) + }) +} + +func TestContextBuilderKindValidation(t *testing.T) { + for _, p := range makeInvalidKindTestParams() { + t.Run(p.kind, func(t *testing.T) { + c := NewContextBuilder().Kind(Kind(p.kind), "my-key").Build() + assert.True(t, c.IsDefined()) + assert.Equal(t, p.err, c.Err()) + }) + } +} + +func TestContextBuilderKeyValidation(t *testing.T) { + c := NewContextBuilder().Kind("user", "").Build() + assert.True(t, c.IsDefined()) + assert.Equal(t, lderrors.ErrContextKeyEmpty{}, c.Err()) +} + +func TestContextBuilderFullyQualifiedKey(t *testing.T) { + t.Run("single kind user", func(t *testing.T) { + c := NewContextBuilder().Kind("user", "my-user-key").Build() + assert.Equal(t, "my-user-key", c.FullyQualifiedKey()) + }) + + t.Run("single kind non-user", func(t *testing.T) { + c := NewContextBuilder().Kind("org", "my-org-key").Build() + assert.Equal(t, "org:my-org-key", c.FullyQualifiedKey()) + }) + + t.Run("multi kind", func(t *testing.T) { + c := NewContextBuilder(). + Kind("kind-c", "key-1"). + Kind("kind-a", "key-2"). + Kind("kind-d", "key-3"). + Kind("kind-b", "key-4"). + Build() + assert.Equal(t, "kind-a:key-2:kind-b:key-4:kind-c:key-1:kind-d:key-3", c.FullyQualifiedKey()) + }) + + t.Run("keys are escaped", func(t *testing.T) { + c := NewContextBuilder(). + Kind("kind-a", "key-1"). + Kind("kind-b", "key:2"). + Build() + assert.Equal(t, "kind-a:key-1:kind-b:key%3A2", c.FullyQualifiedKey()) + }) +} + +func TestKindBuilderSetters(t *testing.T) { + t.Run("Name", func(t *testing.T) { + c := NewContextBuilder().Kind("user", "my-key").Name("my-name").Build() + assert.Equal(t, ldvalue.NewOptionalString("my-name"), c.Name()) + }) + + t.Run("Anonymous", func(t *testing.T) { + c1 := NewContextBuilder().Kind("user", "my-key").Anonymous(false).Build() + assert.False(t, c1.Anonymous()) + + c2 := NewContextBuilder().Kind("user", "my-key").Anonymous(true).Build() + assert.True(t, c2.Anonymous()) + }) + + t.Run("SetValue", func(t *testing.T) { + c := NewContextBuilder().Kind("user", "my-key"). + SetValue("my-attr", ldvalue.Bool(true)). + SetValue("other-attr", ldvalue.String("other-value")). + Build() + + assert.Equal(t, ldvalue.Bool(true), c.GetValue("my-attr")) + assert.Equal(t, ldvalue.String("other-value"), c.GetValue("other-attr")) + }) + + t.Run("typed setters", func(t *testing.T) { + c := NewContextBuilder().Kind("user", "my-key"). + SetBool("bool-attr", true). + SetInt("int-attr", 100). + SetFloat64("float-attr", 1.5). + SetString("string-attr", "x"). + Build() + + assert.Equal(t, ldvalue.Bool(true), c.GetValue("bool-attr")) + assert.Equal(t, ldvalue.Int(100), c.GetValue("int-attr")) + assert.Equal(t, ldvalue.Float64(1.5), c.GetValue("float-attr")) + assert.Equal(t, ldvalue.String("x"), c.GetValue("string-attr")) + }) + + t.Run("Private", func(t *testing.T) { + c := NewContextBuilder().Kind("user", "my-key"). + Name("my-name"). + Private("name", "email"). + Build() + + assert.Equal(t, 2, c.PrivateAttributeCount()) + ref1, ok1 := c.PrivateAttributeByIndex(0) + ref2, ok2 := c.PrivateAttributeByIndex(1) + assert.True(t, ok1) + assert.True(t, ok2) + + refs := []ldattr.Ref{ref1, ref2} + assert.Contains(t, refs, ldattr.NewRef("name")) + assert.Contains(t, refs, ldattr.NewRef("email")) + }) +} + +func TestContextBuilderErrors(t *testing.T) { + t.Run("empty builder", func(t *testing.T) { + c := NewContextBuilder().Build() + assert.True(t, c.IsDefined()) + assert.Equal(t, lderrors.ErrContextKindMultiWithNoKinds{}, c.Err()) + }) + + t.Run("duplicate kind error not applicable", func(t *testing.T) { + // With ContextBuilder, setting the same kind twice just updates it + c := NewContextBuilder(). + Kind("org", "key1"). + Kind("org", "key2"). + Build() + assert.NoError(t, c.Err()) + assert.Equal(t, "key2", c.Key()) + }) + + t.Run("error in individual contexts", func(t *testing.T) { + c := NewContextBuilder(). + Kind("kind1", ""). + Kind("kind2", "my-key"). + Kind("kind3!", "other-key"). + Build() + + assert.Error(t, c.Err()) + if assert.IsType(t, lderrors.ErrContextPerKindErrors{}, c.Err()) { + e := c.Err().(lderrors.ErrContextPerKindErrors) + assert.Len(t, e.Errors, 2) + assert.Equal(t, lderrors.ErrContextKeyEmpty{}, e.Errors["kind1"]) + assert.Equal(t, lderrors.ErrContextKindInvalidChars{}, e.Errors["kind3!"]) + } + }) +} + +func TestContextBuilderChaining(t *testing.T) { + t.Run("method chaining works", func(t *testing.T) { + c := NewContextBuilder(). + Kind("user", "user-key").Name("User Name").Anonymous(true). + Kind("org", "org-key").SetString("type", "enterprise"). + Build() + + assert.Equal(t, 2, c.IndividualContextCount()) + + userCtx := c.IndividualContextByKind("user") + assert.Equal(t, "user-key", userCtx.Key()) + assert.Equal(t, ldvalue.NewOptionalString("User Name"), userCtx.Name()) + assert.True(t, userCtx.Anonymous()) + + orgCtx := c.IndividualContextByKind("org") + assert.Equal(t, "org-key", orgCtx.Key()) + assert.Equal(t, ldvalue.String("enterprise"), orgCtx.GetValue("type")) + }) +} + +func TestContextBuilderSafety(t *testing.T) { + t.Run("empty instance is safe to use", func(t *testing.T) { + var emptyInstance ContextBuilder + c := emptyInstance.Kind("user", "a").Build() + assert.Equal(t, "a", c.Key()) + }) + + t.Run("nil pointer is safe to use", func(t *testing.T) { + var nilPtr *ContextBuilder + assert.Nil(t, nilPtr.Kind("user", "a")) + assert.Equal(t, Context{}, nilPtr.Build()) + }) +} + +func TestKindBuilderSafety(t *testing.T) { + t.Run("nil pointer is safe to use", func(t *testing.T) { + var nilPtr *KindBuilder + assert.Nil(t, nilPtr.Name("a")) + assert.Nil(t, nilPtr.Anonymous(true)) + assert.Nil(t, nilPtr.SetValue("a", ldvalue.Bool(true))) + assert.Nil(t, nilPtr.Private("a")) + }) +} From c7edd72e209162927e8c9e624b48cf6a4593e2ae Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 10:11:48 -0700 Subject: [PATCH 04/20] Remove safety tests, not sure if we need this --- ldcontext/builder_new.go | 13 +++++++++++-- ldcontext/builder_new_test.go | 24 ------------------------ 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go index 59bb68c..97eabd2 100644 --- a/ldcontext/builder_new.go +++ b/ldcontext/builder_new.go @@ -61,15 +61,24 @@ func (cb *ContextBuilder) Build() Context { kind: MultiKind, multiContexts: make([]Context, 0, len(cb.singleBuilders)), } + + var individualErrors map[string]error for _, kind := range kinds { singleBuilder := cb.singleBuilders[Kind(kind)] ctx, err := singleBuilder.TryBuild() if err != nil { - ret.err = err - return ret + if individualErrors == nil { + individualErrors = make(map[string]error) + } + individualErrors[kind] = err + continue } ret.multiContexts = append(ret.multiContexts, ctx) } + if len(individualErrors) != 0 { + ret.err = lderrors.ErrContextPerKindErrors{Errors: individualErrors} + return ret + } // Fully-qualified key for multi-context is defined as "kind1:key1:kind2:key2" etc., where kinds are in // alphabetical order (we have already sorted them above) and keys are URL-encoded. In this case we diff --git a/ldcontext/builder_new_test.go b/ldcontext/builder_new_test.go index 7dbd45b..0423368 100644 --- a/ldcontext/builder_new_test.go +++ b/ldcontext/builder_new_test.go @@ -259,27 +259,3 @@ func TestContextBuilderChaining(t *testing.T) { assert.Equal(t, ldvalue.String("enterprise"), orgCtx.GetValue("type")) }) } - -func TestContextBuilderSafety(t *testing.T) { - t.Run("empty instance is safe to use", func(t *testing.T) { - var emptyInstance ContextBuilder - c := emptyInstance.Kind("user", "a").Build() - assert.Equal(t, "a", c.Key()) - }) - - t.Run("nil pointer is safe to use", func(t *testing.T) { - var nilPtr *ContextBuilder - assert.Nil(t, nilPtr.Kind("user", "a")) - assert.Equal(t, Context{}, nilPtr.Build()) - }) -} - -func TestKindBuilderSafety(t *testing.T) { - t.Run("nil pointer is safe to use", func(t *testing.T) { - var nilPtr *KindBuilder - assert.Nil(t, nilPtr.Name("a")) - assert.Nil(t, nilPtr.Anonymous(true)) - assert.Nil(t, nilPtr.SetValue("a", ldvalue.Bool(true))) - assert.Nil(t, nilPtr.Private("a")) - }) -} From 686ca02de3068bbf3de5db99f6101e7776b4a473 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 10:29:21 -0700 Subject: [PATCH 05/20] empty kind rules --- ldcontext/builder_new.go | 7 +++---- ldcontext/builder_new_test.go | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go index 97eabd2..f2be810 100644 --- a/ldcontext/builder_new.go +++ b/ldcontext/builder_new.go @@ -22,6 +22,9 @@ func NewContextBuilder() *ContextBuilder { } func (cb *ContextBuilder) Kind(kind Kind, key string) *KindBuilder { + if kind == "" { + kind = DefaultKind + } singleBuilder, ok := cb.singleBuilders[kind] if !ok { singleBuilder = NewBuilder(key).Kind(kind) @@ -36,10 +39,6 @@ func (cb *ContextBuilder) Build() Context { return Context{defined: true, err: lderrors.ErrContextKindMultiWithNoKinds{}} } - // TODO: calling `.Build()` on a Builder does mostly what we want, but it also - // converts a "" kind to "user". This builder should instead probably enforce that you provide - // non-empty kinds. - if len(cb.singleBuilders) == 1 { // If only one context kind was added, the result is just the same as building that one context for _, singleBuilder := range cb.singleBuilders { diff --git a/ldcontext/builder_new_test.go b/ldcontext/builder_new_test.go index 0423368..8bb57b1 100644 --- a/ldcontext/builder_new_test.go +++ b/ldcontext/builder_new_test.go @@ -114,6 +114,23 @@ func TestContextBuilderKindValidation(t *testing.T) { } } +func TestContextBuilderEmptyKind(t *testing.T) { + t.Run("empty kind is equivalent to default kind", func(t *testing.T) { + c := NewContextBuilder().Kind("", "my-key").Build() + assert.Equal(t, DefaultKind, c.Kind()) + }) + t.Run("empty kind in a multi context", func(t *testing.T) { + c := NewContextBuilder(). + Kind("", "key1"). + Kind("user", "key2"). + Kind("org", "key3"). + Build() + assert.Equal(t, 2, c.IndividualContextCount()) + assert.Equal(t, "key2", c.IndividualContextKeyByKind("user")) + assert.Equal(t, "key3", c.IndividualContextKeyByKind("org")) + }) +} + func TestContextBuilderKeyValidation(t *testing.T) { c := NewContextBuilder().Kind("user", "").Build() assert.True(t, c.IsDefined()) From 43fde68e05297cdd18bad4dc6b3507cd99402f06 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 12:36:43 -0700 Subject: [PATCH 06/20] Add comments to ContextBuilder and KindBuilder methods --- ldcontext/builder_new.go | 295 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go index f2be810..ea335e6 100644 --- a/ldcontext/builder_new.go +++ b/ldcontext/builder_new.go @@ -8,19 +8,123 @@ import ( "github.com/launchdarkly/go-sdk-common/v3/ldvalue" ) +// ContextBuilder is a mutable object that uses the builder pattern to specify properties for a Context. +// +// Obtain an instance of ContextBuilder by calling [NewContextBuilder]. Then, call [ContextBuilder.Kind] +// to start defining a context with a specific Kind. This returns a [KindBuilder] that can set properties +// for that Kind. KindBuilders support chaining: call Kind again to start defining a different Kind. This +// builds a multi-context. Finally, call Build on any KindBuilder to produce the immutable [Context]. +// +// context := ldcontext.NewContextBuilder(). +// Kind("user", "user-key"). +// Name("my-name"). +// SetString("country", "us"). +// Kind("org", "org-key"). +// SetString("type", "enterprise"). +// Build() +// +// A ContextBuilder and its KindBuilders should not be accessed by multiple goroutines at once. +// Once you have called [ContextBuilder.Build], the resulting Context is immutable and safe to +// use from multiple goroutines. +// +// # Context attributes +// +// There are several built-in attribute names with special meaning in LaunchDarkly, and +// restrictions on the type of their value. These have their own builder methods: see +// [KindBuilder.Key], [KindBuilder.Name], [KindBuilder.Anonymous], and [KindBuilder.SetValue]. +// +// You may also set any number of other attributes with whatever names are useful for your +// application (subject to validation constraints; see [KindBuilder.SetValue] for rules regarding +// attribute names). These attributes can have any data type that is supported in JSON: +// boolean, number, string, array, or object. +// +// # Setting attributes with simple value types +// +// For convenience, there are setter methods for simple types: +// +// context := ldcontext.NewContextBuilder(). +// Kind("user", "user-key"). +// SetBool("a", true). // this attribute has a boolean value +// SetString("b", "xyz"). // this attribute has a string value +// SetInt("c", 3). // this attribute has an integer numeric value +// SetFloat64("d", 4.5). // this attribute has a floating-point numeric value +// Build() +// +// # Setting attributes with complex value types +// +// JSON arrays and objects are represented by the [ldvalue.Value] type. The [KindBuilder.SetValue] +// method takes a value of this type. +// +// The [ldvalue] package provides several ways to construct values of each type. Here are some examples; +// for more information, see [ldvalue.Value]. +// +// context := ldcontext.NewContextBuilder(). +// Kind("user", "user-key"). +// SetValue("arrayAttr1", +// ldvalue.ArrayOf(ldvalue.String("a"), ldvalue.String("b"))). +// SetValue("arrayAttr2", +// ldvalue.CopyArbitraryValue([]string{"a", "b"})). +// SetValue("objectAttr1", +// ldvalue.ObjectBuild().SetString("color", "green").Build()). +// SetValue("objectAttr2", +// ldvalue.FromJSONMarshal(MyStructType{Color: "green"})). +// Build() +// +// Arrays and objects have special meanings in LaunchDarkly flag evaluation: +// - An array of values means "try to match any of these values to the targeting rule." +// - An object allows you to match a property within the object to the targeting rule. For instance, +// in the example above, a targeting rule could reference /objectAttr1/color to match the value +// "green". Nested property references like /objectAttr1/address/street are allowed if a property +// contains another JSON object. +// +// # Private attributes +// +// You may designate certain attributes, or values within them, as "private", meaning that their +// values are not included in analytics data sent to LaunchDarkly. See [KindBuilder.Private]. +// +// context := ldcontext.NewContextBuilder(). +// Kind("user", "user-key"). +// SetString("email", "test@example.com"). +// Private("email"). +// Build() type ContextBuilder struct { singleBuilders map[Kind]*Builder } +// KindBuilder is a mutable object that uses the builder pattern to specify properties for a context +// of a specific Kind within a multi-context. +// +// See [ContextBuilder] for more about how to use this type. type KindBuilder struct { *ContextBuilder singleBuilder *Builder } +// NewContextBuilder creates a ContextBuilder for building a single or multi-context. +// +// See [ContextBuilder] for more information. func NewContextBuilder() *ContextBuilder { return &ContextBuilder{singleBuilders: make(map[Kind]*Builder)} } +// Kind starts building a new context with the specified kind and key in the current +// ContextBuilder, returning a [KindBuilder] that can set properties for that inner context. +// If a context with that kind has already been defined, its key is set to the new value, +// all other properties are preserved, and the returned KindBuilder can be used to replace or +// add additional properties. +// +// Every [Context] has a kind. Setting it to an empty string is equivalent to the default kind of +// "user". This value is case-sensitive. Validation rules are as follows: +// +// - It may only contain letters, numbers, and the characters ".", "_", and "-". +// - It cannot equal the literal string "kind". +// - It cannot equal the literal string "multi" ([MultiKind]). +// +// The key parameter is the value of the context's "key" attribute. It is always a string. +// There are no restrictions on its value except that it cannot be empty. +// +// If either value is invalid at the time [ContextBuilder.Build] is called, you will receive an +// invalid Context whose [Context.Err] value will describe the problem. func (cb *ContextBuilder) Kind(kind Kind, key string) *KindBuilder { if kind == "" { kind = DefaultKind @@ -34,6 +138,20 @@ func (cb *ContextBuilder) Kind(kind Kind, key string) *KindBuilder { return &KindBuilder{ContextBuilder: cb, singleBuilder: singleBuilder} } +// Build creates a Context from the current ContextBuilder and KindBuilder properties. +// +// The [Context] is immutable and will not be affected by any subsequent actions on the ContextBuilder. +// +// It is possible for a ContextBuilder to represent an invalid state. Instead of returning two +// values (Context, error), Build always returns a Context and you can call [Context.Err] +// to see if it has an error. See [Context.Err] for more information about invalid Context +// conditions. Using a single-return-value syntax is more convenient for application code, since +// in normal usage an application will never build an invalid Context. If you pass an invalid +// Context to an SDK method, the SDK will detect this and will generally log a description +// of the error. +// +// You may call [ContextBuilder.TryBuild] instead of Build if you prefer to use two-value return +// semantics, but the validation behavior is the same for both. func (cb *ContextBuilder) Build() Context { if len(cb.singleBuilders) == 0 { return Context{defined: true, err: lderrors.ErrContextKindMultiWithNoKinds{}} @@ -92,75 +210,252 @@ func (cb *ContextBuilder) Build() Context { return ret } +// TryBuild is an alternative to Build that returns any validation errors as a second value. +// +// As described in [ContextBuilder.Build], there are several ways the state of a [Context] could +// be invalid. Since in normal usage it is possible to be confident that these will not occur, +// the Build method is designed for convenient use within expressions by returning a single +// Context value, and any validation problems are contained within that value where they can be +// detected by calling the context's [Context.Err] method. But, if you prefer to use the +// two-value pattern that is common in Go, you can call TryBuild instead: +// +// c, err := ldcontext.NewContextBuilder(). +// Kind("user", "user-key"). +// Name("my-name"). +// TryBuild() +// if err != nil { +// // do whatever is appropriate if building the context failed +// } +// +// The two return values are the same as to 1. the Context that would be returned by Build(), +// and 2. the result of calling [Context.Err] on that Context. So, the above example is exactly +// equivalent to: +// +// c := ldcontext.NewContextBuilder(). +// Kind("user", "user-key"). +// Name("my-name"). +// Build() +// if c.Err() != nil { +// // do whatever is appropriate if building the context failed +// } +// +// Note that unlike some Go methods where the first return value is normally an +// uninitialized zero value if the error is non-nil, the Context returned by TryBuild in case +// of an error is not completely uninitialized: it does contain the error information as well, +// so that if it is mistakenly passed to an SDK method, the SDK can tell what the error was. func (cb *ContextBuilder) TryBuild() (Context, error) { c := cb.Build() return c, c.Err() } +// Key sets the Context's key attribute. +// +// Every [Context] has a key, which is always a string. There are no restrictions on its value except +// that it cannot be empty. +// +// The key attribute can be referenced by flag rules, flag target lists, and segments. +// +// If the key is empty at the time [ContextBuilder.Build] is called, you will receive an invalid Context +// whose [Context.Err] value will describe the problem. func (kb *KindBuilder) Key(key string) *KindBuilder { kb.singleBuilder.Key(key) return kb } +// Name sets the Context's name attribute. +// +// This attribute is optional. It has the following special rules: +// - Unlike most other attributes, it is always a string if it is specified. +// - The LaunchDarkly dashboard treats this attribute as the preferred display name for contexts. func (kb *KindBuilder) Name(name string) *KindBuilder { kb.singleBuilder.Name(name) return kb } +// OptName sets or clears the Context's name attribute. +// +// Calling b.OptName(ldvalue.NewOptionalString("x")) is equivalent to b.Name("x"), but since it uses +// the OptionalString type, it also allows clearing a previously set name with +// b.OptName(ldvalue.OptionalString{}). func (kb *KindBuilder) OptName(name ldvalue.OptionalString) *KindBuilder { kb.singleBuilder.OptName(name) return kb } +// SetBool sets an attribute to a boolean value. +// +// For rules regarding attribute names and values, see [KindBuilder.SetValue]. This method is exactly +// equivalent to calling b.SetValue(attributeName, ldvalue.Bool(value)). func (kb *KindBuilder) SetBool(attributeName string, value bool) *KindBuilder { kb.singleBuilder.SetBool(attributeName, value) return kb } +// SetFloat64 sets an attribute to a float64 numeric value. +// +// For rules regarding attribute names and values, see [KindBuilder.SetValue]. This method is exactly +// equivalent to calling b.SetValue(attributeName, ldvalue.Float64(value)). +// +// Note: the LaunchDarkly model for feature flags and user attributes is based on JSON types, +// and JSON does not distinguish between integer and floating-point types. Therefore, +// b.SetFloat64(name, float64(1.0)) is exactly equivalent to b.SetInt(name, 1). func (kb *KindBuilder) SetFloat64(attributeName string, value float64) *KindBuilder { kb.singleBuilder.SetFloat64(attributeName, value) return kb } +// SetInt sets an attribute to an int numeric value. +// +// For rules regarding attribute names and values, see [KindBuilder.SetValue]. This method is exactly +// equivalent to calling b.SetValue(attributeName, ldvalue.Int(value)). +// +// Note: the LaunchDarkly model for feature flags and user attributes is based on JSON types, +// and JSON does not distinguish between integer and floating-point types. Therefore, +// b.SetFloat64(name, float64(1.0)) is exactly equivalent to b.SetInt(name, 1). func (kb *KindBuilder) SetInt(attributeName string, value int) *KindBuilder { kb.singleBuilder.SetInt(attributeName, value) return kb } +// SetString sets an attribute to a string value. +// +// For rules regarding attribute names and values, see [KindBuilder.SetValue]. This method is exactly +// equivalent to calling b.SetValue(attributeName, ldvalue.String(value)). func (kb *KindBuilder) SetString(attributeName string, value string) *KindBuilder { kb.singleBuilder.SetString(attributeName, value) return kb } +// SetValue sets the value of any attribute for the Context. +// +// This method uses the [ldvalue.Value] type to represent a value of any JSON type: boolean, +// number, string, array, or object. The [ldvalue] package provides several ways to construct +// values of each type. +// +// The return value is always the same [KindBuilder], for convenience (to allow method chaining). +// +// # Allowable attribute names +// +// The attribute names "kind", "key", "name", and "anonymous" have special meaning in +// LaunchDarkly. You may not set "kind" with SetValue; that must be specified with [ContextBuilder.Kind]. +// +// You may set "key", "name", and "anonymous" with SetValue, as an alternative to using the +// methods [KindBuilder.Key], [KindBuilder.Name], and [KindBuilder.Anonymous]. However, +// there are restrictions on the value type: "key" must be a string, "name" must +// be a string or null, and "anonymous" must be a boolean. Any value of an unsupported type +// is ignored (leaving the attribute unchanged). func (kb *KindBuilder) SetValue(attributeName string, value ldvalue.Value) *KindBuilder { + // TODO: disallow setting Kind with this kb.singleBuilder.SetValue(attributeName, value) return kb } +// TrySetValue sets the value of any attribute for the Context. +// +// This is the same as [KindBuilder.SetValue], except that it returns true for success, or false if the +// parameters violated one of the restrictions described for SetValue (for instance, +// attempting to set "key" to a value that was not a string). func (kb *KindBuilder) TrySetValue(attributeName string, value ldvalue.Value) bool { return kb.singleBuilder.TrySetValue(attributeName, value) } +// Anonymous sets whether the Context is only intended for flag evaluations and should not be indexed by +// LaunchDarkly. +// +// The default value is false. False means that this [Context] represents an entity such as a user that you +// want to be able to see on the LaunchDarkly dashboard. +// +// Setting Anonymous to true excludes this Context from the database that is used by the dashboard. It does +// not exclude it from analytics event data, so it is not the same as making attributes private; all +// non-private attributes will still be included in events and data export. There is no limitation on what +// other attributes may be included (so, for instance, Anonymous does not mean there is no [KindBuilder.Name]). +// +// This value is also addressable in evaluations as the attribute name "anonymous". It is always treated as +// a boolean true or false in evaluations; it cannot be null/undefined. func (kb *KindBuilder) Anonymous(value bool) *KindBuilder { kb.singleBuilder.Anonymous(value) return kb } +// Private designates any number of Context attributes, or properties within them, as private: that is, +// their values will not be sent to LaunchDarkly in analytics data. +// +// This action only affects analytics events that involve this particular [Context]. To mark some (or all) +// Context attributes as private for all context, use the overall event configuration for the SDK. +// +// In this example, firstName is marked as private, but lastName is not: +// +// c := ldcontext.NewContextBuilder(). +// Kind("user", "my-key"). +// SetString("firstName", "Pierre"). +// SetString("lastName", "Menard"). +// Private("firstName"). +// Build() +// +// The attributes "kind", "key", and "anonymous" cannot be made private. +// +// This is a metadata property, rather than an attribute that can be addressed in evaluations: that is, +// a rule clause that references the attribute name "private" will not use this value, but instead will +// use whatever value (if any) you have set for that name with a method such as [KindBuilder.SetString]. +// +// # Designating an entire attribute as private +// +// If the parameter is an attribute name such as "email" that does not start with a '/' character, the +// entire attribute is private. +// +// # Designating a property within a JSON object as private +// +// If the parameter starts with a '/' character, it is interpreted as a slash-delimited path to a +// property within a JSON object. The first path component is an attribute name, and each following +// component is a property name. +// +// For instance, suppose that the attribute "address" had the following JSON object value: +// {"street": {"line1": "abc", "line2": "def"}, "city": "ghi"} +// +// - Calling either Private("address") or Private("/address") would cause the entire "address" +// attribute to be private. +// - Calling Private("/address/street") would cause the "street" property to be private, so that +// only {"city": "ghi"} is included in analytics. +// - Calling Private("/address/street/line2") would cause only "line2" within "street" to be private, +// so that {"street": {"line1": "abc"}, "city": "ghi"} is included in analytics. +// +// This syntax deliberately resembles JSON Pointer, but other JSON Pointer features such as array +// indexing are not supported for Private. +// +// If an attribute's actual name starts with a '/' character, you must use the same escaping syntax as +// JSON Pointer: replace "~" with "~0", and "/" with "~1". func (kb *KindBuilder) Private(attrRefStrings ...string) *KindBuilder { kb.singleBuilder.Private(attrRefStrings...) return kb } +// PrivateRef is equivalent to Private, but uses the ldattr.Ref type. It designates any number of +// Context attributes, or properties within them, as private: that is, their values will not be +// sent to LaunchDarkly. +// +// Application code is unlikely to need to use the ldattr.Ref type directly; however, in cases where +// you are constructing Contexts constructed repeatedly with the same set of private attributes, if +// you are also using complex private attribute path references such as "/address/street", converting +// this to an [ldattr.Ref] once and reusing it in many PrivateRef calls is slightly more efficient than +// calling [KindBuilder.Private] (since it does not need to parse the path repeatedly). func (kb *KindBuilder) PrivateRef(attrRefs ...ldattr.Ref) *KindBuilder { kb.singleBuilder.PrivateRef(attrRefs...) return kb } +// RemovePrivate removes any private attribute references previously added with [KindBuilder.Private] +// or [KindBuilder.PrivateRef] that exactly match any of the specified attribute references. func (kb *KindBuilder) RemovePrivate(attrRefStrings ...string) *KindBuilder { kb.singleBuilder.RemovePrivate(attrRefStrings...) return kb } +// RemovePrivateRef removes any private attribute references previously added with [KindBuilder.Private] +// or [KindBuilder.PrivateRef] that exactly match that of any of the specified attribute references. +// +// Application code is unlikely to need to use the [ldattr.Ref] type directly, and can use +// RemovePrivate with a string parameter to accomplish the same thing. This method is mainly for +// use by internal LaunchDarkly SDK and service code which uses ldattr.Ref. func (kb *KindBuilder) RemovePrivateRef(attrRefs ...ldattr.Ref) *KindBuilder { kb.singleBuilder.RemovePrivateRef(attrRefs...) return kb From 2a3a5ba2f4ef7f6485d44e5f208412dddc709298 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 15:03:18 -0700 Subject: [PATCH 07/20] Don't allow setting kind in SetAttribute --- ldcontext/builder_new.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go index ea335e6..f24fac6 100644 --- a/ldcontext/builder_new.go +++ b/ldcontext/builder_new.go @@ -345,8 +345,7 @@ func (kb *KindBuilder) SetString(attributeName string, value string) *KindBuilde // be a string or null, and "anonymous" must be a boolean. Any value of an unsupported type // is ignored (leaving the attribute unchanged). func (kb *KindBuilder) SetValue(attributeName string, value ldvalue.Value) *KindBuilder { - // TODO: disallow setting Kind with this - kb.singleBuilder.SetValue(attributeName, value) + _ = kb.TrySetValue(attributeName, value) return kb } @@ -356,7 +355,14 @@ func (kb *KindBuilder) SetValue(attributeName string, value ldvalue.Value) *Kind // parameters violated one of the restrictions described for SetValue (for instance, // attempting to set "key" to a value that was not a string). func (kb *KindBuilder) TrySetValue(attributeName string, value ldvalue.Value) bool { - return kb.singleBuilder.TrySetValue(attributeName, value) + // We mostly defer to kb.singlebuilder.TrySetValue, but we have an extra restriction + // that you cannot set "kind" with SetValue. + switch attributeName { + case ldattr.KindAttr: + return false + default: + return kb.singleBuilder.TrySetValue(attributeName, value) + } } // Anonymous sets whether the Context is only intended for flag evaluations and should not be indexed by From 93c6a620c12ffc2a28b32a8711ad571949c1b2d4 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 15:03:31 -0700 Subject: [PATCH 08/20] Start rewriting unit tests: copy over simple tests --- ldcontext/builder_new_test.go | 528 ++++++++++++++++++++-------------- 1 file changed, 318 insertions(+), 210 deletions(-) diff --git a/ldcontext/builder_new_test.go b/ldcontext/builder_new_test.go index 8bb57b1..3a76a46 100644 --- a/ldcontext/builder_new_test.go +++ b/ldcontext/builder_new_test.go @@ -1,278 +1,386 @@ package ldcontext import ( + "fmt" "testing" + "github.com/stretchr/testify/assert" + "github.com/launchdarkly/go-sdk-common/v3/ldattr" "github.com/launchdarkly/go-sdk-common/v3/lderrors" "github.com/launchdarkly/go-sdk-common/v3/ldvalue" - - "github.com/stretchr/testify/assert" + "github.com/launchdarkly/go-test-helpers/v3/jsonhelpers" ) -func TestContextBuilderSingleKind(t *testing.T) { - t.Run("basic properties", func(t *testing.T) { - c := NewContextBuilder().Kind("user", "my-key").Build() - assert.True(t, c.IsDefined()) - assert.NoError(t, c.Err()) - assert.Equal(t, DefaultKind, c.Kind()) - assert.Equal(t, "my-key", c.Key()) - assert.Equal(t, ldvalue.OptionalString{}, c.Name()) - assert.False(t, c.Anonymous()) - }) - - t.Run("custom kind", func(t *testing.T) { - c := NewContextBuilder().Kind("org", "my-org-key").Build() - assert.Equal(t, Kind("org"), c.Kind()) - assert.Equal(t, "my-org-key", c.Key()) - }) - - t.Run("with name", func(t *testing.T) { - c := NewContextBuilder().Kind("user", "my-key").Name("my-name").Build() - assert.Equal(t, ldvalue.NewOptionalString("my-name"), c.Name()) - }) +// Tests are adapted from builder_simple_test.go and builder_multi_test.go. - t.Run("with anonymous", func(t *testing.T) { - c := NewContextBuilder().Kind("user", "my-key").Anonymous(true).Build() - assert.True(t, c.Anonymous()) - }) - - t.Run("with custom attributes", func(t *testing.T) { - c := NewContextBuilder().Kind("user", "my-key"). - SetString("email", "test@example.com"). - SetBool("active", true). - Build() +// SINGLE KIND TESTS (adapted from builder_simple_test.go) - assert.Equal(t, ldvalue.String("test@example.com"), c.GetValue("email")) - assert.Equal(t, ldvalue.Bool(true), c.GetValue("active")) - }) - - t.Run("with private attributes", func(t *testing.T) { - c := NewContextBuilder().Kind("user", "my-key"). - Name("my-name"). - Private("name", "email"). - Build() - - assert.Equal(t, 2, c.PrivateAttributeCount()) - }) +func makeKindBuilder() *KindBuilder { + // for test cases where the kind and key are unimportant + return NewContextBuilder().Kind("user", "my-key") } -func TestContextBuilderMultiKind(t *testing.T) { - t.Run("two kinds", func(t *testing.T) { - c := NewContextBuilder(). - Kind("user", "user-key").Name("User Name"). - Kind("org", "org-key").Name("Org Name"). - Build() - - assert.True(t, c.Multiple()) - assert.Equal(t, Kind("multi"), c.Kind()) - assert.Equal(t, 2, c.IndividualContextCount()) - - userCtx := c.IndividualContextByKind("user") - assert.Equal(t, "user-key", userCtx.Key()) - assert.Equal(t, ldvalue.NewOptionalString("User Name"), userCtx.Name()) - - orgCtx := c.IndividualContextByKind("org") - assert.Equal(t, "org-key", orgCtx.Key()) - assert.Equal(t, ldvalue.NewOptionalString("Org Name"), orgCtx.Name()) - }) - - t.Run("three kinds", func(t *testing.T) { - c := NewContextBuilder(). - Kind("user", "user-key"). - Kind("org", "org-key"). - Kind("device", "device-key"). - Build() - - assert.Equal(t, 3, c.IndividualContextCount()) - assert.NotEqual(t, Context{}, c.IndividualContextByKind("user")) - assert.NotEqual(t, Context{}, c.IndividualContextByKind("org")) - assert.NotEqual(t, Context{}, c.IndividualContextByKind("device")) - }) - - t.Run("updating existing kind", func(t *testing.T) { - c := NewContextBuilder(). - Kind("user", "user-key-1").Name("Name 1"). - Kind("org", "org-key"). - Kind("user", "user-key-2").Name("Name 2"). - Build() - - assert.Equal(t, 2, c.IndividualContextCount()) - userCtx := c.IndividualContextByKind("user") - assert.Equal(t, "user-key-2", userCtx.Key()) - assert.Equal(t, ldvalue.NewOptionalString("Name 2"), userCtx.Name()) - }) +func TestKindBuilderDefaultProperties(t *testing.T) { + c := makeKindBuilder().Build() + assert.True(t, c.IsDefined()) + assert.NoError(t, c.Err()) + assert.Equal(t, DefaultKind, c.Kind()) + assert.Equal(t, "my-key", c.Key()) + + assert.Equal(t, ldvalue.OptionalString{}, c.Name()) + assert.False(t, c.Anonymous()) + assert.Equal(t, ldvalue.OptionalString{}, c.Secondary()) + assert.Len(t, c.GetOptionalAttributeNames(nil), 0) } -func TestContextBuilderKindValidation(t *testing.T) { +func TestKindBuilderKindValidation(t *testing.T) { for _, p := range makeInvalidKindTestParams() { t.Run(p.kind, func(t *testing.T) { - c := NewContextBuilder().Kind(Kind(p.kind), "my-key").Build() - assert.True(t, c.IsDefined()) - assert.Equal(t, p.err, c.Err()) + b := NewContextBuilder().Kind(Kind(p.kind), "my-key") + + c0 := b.Build() + assert.True(t, c0.IsDefined()) + assert.Equal(t, p.err, c0.Err()) + + c1, err := b.TryBuild() + assert.True(t, c1.IsDefined()) + assert.Equal(t, p.err, c1.Err()) + assert.Equal(t, p.err, err) }) } } -func TestContextBuilderEmptyKind(t *testing.T) { - t.Run("empty kind is equivalent to default kind", func(t *testing.T) { - c := NewContextBuilder().Kind("", "my-key").Build() - assert.Equal(t, DefaultKind, c.Kind()) - }) - t.Run("empty kind in a multi context", func(t *testing.T) { - c := NewContextBuilder(). - Kind("", "key1"). - Kind("user", "key2"). - Kind("org", "key3"). - Build() - assert.Equal(t, 2, c.IndividualContextCount()) - assert.Equal(t, "key2", c.IndividualContextKeyByKind("user")) - assert.Equal(t, "key3", c.IndividualContextKeyByKind("org")) - }) -} +func TestKindBuilderKeyValidation(t *testing.T) { + b := NewContextBuilder().Kind("user", "") -func TestContextBuilderKeyValidation(t *testing.T) { - c := NewContextBuilder().Kind("user", "").Build() - assert.True(t, c.IsDefined()) - assert.Equal(t, lderrors.ErrContextKeyEmpty{}, c.Err()) + c0 := b.Build() + assert.True(t, c0.IsDefined()) + assert.Equal(t, lderrors.ErrContextKeyEmpty{}, c0.Err()) + + c1, err := b.TryBuild() + assert.True(t, c1.IsDefined()) + assert.Equal(t, lderrors.ErrContextKeyEmpty{}, c1.Err()) + assert.Equal(t, lderrors.ErrContextKeyEmpty{}, err) } -func TestContextBuilderFullyQualifiedKey(t *testing.T) { - t.Run("single kind user", func(t *testing.T) { - c := NewContextBuilder().Kind("user", "my-user-key").Build() +func TestKindBuilderFullyQualifiedKey(t *testing.T) { + t.Run("kind is user", func(t *testing.T) { + c := New("my-user-key") assert.Equal(t, "my-user-key", c.FullyQualifiedKey()) }) - t.Run("single kind non-user", func(t *testing.T) { - c := NewContextBuilder().Kind("org", "my-org-key").Build() + t.Run("kind is not user", func(t *testing.T) { + c := NewWithKind("org", "my-org-key") assert.Equal(t, "org:my-org-key", c.FullyQualifiedKey()) }) - t.Run("multi kind", func(t *testing.T) { - c := NewContextBuilder(). - Kind("kind-c", "key-1"). - Kind("kind-a", "key-2"). - Kind("kind-d", "key-3"). - Kind("kind-b", "key-4"). - Build() - assert.Equal(t, "kind-a:key-2:kind-b:key-4:kind-c:key-1:kind-d:key-3", c.FullyQualifiedKey()) + t.Run("key is escaped", func(t *testing.T) { + c := NewWithKind("org", "my:key%x/y") + assert.Equal(t, "org:my%3Akey%25x/y", c.FullyQualifiedKey()) }) +} - t.Run("keys are escaped", func(t *testing.T) { - c := NewContextBuilder(). - Kind("kind-a", "key-1"). - Kind("kind-b", "key:2"). - Build() - assert.Equal(t, "kind-a:key-1:kind-b:key%3A2", c.FullyQualifiedKey()) +func TestKindBuilderBasicSetters(t *testing.T) { + t.Run("Key", func(t *testing.T) { + assert.Equal(t, "other-key", makeKindBuilder().Key("other-key").Build().Key()) }) -} -func TestKindBuilderSetters(t *testing.T) { t.Run("Name", func(t *testing.T) { - c := NewContextBuilder().Kind("user", "my-key").Name("my-name").Build() - assert.Equal(t, ldvalue.NewOptionalString("my-name"), c.Name()) + c0 := makeKindBuilder().Build() + assert.Equal(t, ldvalue.OptionalString{}, c0.Name()) + + c1 := makeKindBuilder().Name("my-name").Build() + assert.Equal(t, ldvalue.NewOptionalString("my-name"), c1.Name()) + + c2 := makeKindBuilder().OptName(ldvalue.OptionalString{}).Build() + assert.Equal(t, ldvalue.OptionalString{}, c2.Name()) + + c3 := makeKindBuilder().OptName(ldvalue.NewOptionalString("my-name")).Build() + assert.Equal(t, ldvalue.NewOptionalString("my-name"), c3.Name()) + }) + + t.Run("Secondary", func(t *testing.T) { + c0 := makeKindBuilder().Build() + assert.Equal(t, ldvalue.OptionalString{}, c0.Secondary()) }) t.Run("Anonymous", func(t *testing.T) { - c1 := NewContextBuilder().Kind("user", "my-key").Anonymous(false).Build() + c0 := makeKindBuilder().Build() + assert.False(t, c0.Anonymous()) + + c1 := makeKindBuilder().Anonymous(false).Build() assert.False(t, c1.Anonymous()) - c2 := NewContextBuilder().Kind("user", "my-key").Anonymous(true).Build() + c2 := makeKindBuilder().Anonymous(true).Build() assert.True(t, c2.Anonymous()) }) +} +func TestKindBuilderSetCustomAttributes(t *testing.T) { t.Run("SetValue", func(t *testing.T) { - c := NewContextBuilder().Kind("user", "my-key"). - SetValue("my-attr", ldvalue.Bool(true)). - SetValue("other-attr", ldvalue.String("other-value")). - Build() - - assert.Equal(t, ldvalue.Bool(true), c.GetValue("my-attr")) - assert.Equal(t, ldvalue.String("other-value"), c.GetValue("other-attr")) + otherValue := ldvalue.String("other-value") + for _, value := range []ldvalue.Value{ + ldvalue.Bool(true), + ldvalue.Bool(false), + ldvalue.Int(0), + ldvalue.Int(1), + ldvalue.String(""), + ldvalue.String("x"), + ldvalue.ArrayOf(ldvalue.Int(1), ldvalue.Int(2)), + ldvalue.ObjectBuild().Set("a", ldvalue.Int(1)).Build(), + } { + t.Run(value.JSONString(), func(t *testing.T) { + c := makeKindBuilder(). + SetValue("my-attr", value). + SetValue("other-attr", otherValue). + Build() + assert.Len(t, c.attributes.Keys(nil), 2) + jsonhelpers.AssertEqual(t, value, c.attributes.Get("my-attr")) + jsonhelpers.AssertEqual(t, otherValue, c.attributes.Get("other-attr")) + }) + } }) t.Run("typed setters", func(t *testing.T) { - c := NewContextBuilder().Kind("user", "my-key"). - SetBool("bool-attr", true). - SetInt("int-attr", 100). - SetFloat64("float-attr", 1.5). - SetString("string-attr", "x"). - Build() - - assert.Equal(t, ldvalue.Bool(true), c.GetValue("bool-attr")) - assert.Equal(t, ldvalue.Int(100), c.GetValue("int-attr")) - assert.Equal(t, ldvalue.Float64(1.5), c.GetValue("float-attr")) - assert.Equal(t, ldvalue.String("x"), c.GetValue("string-attr")) + // For the typed setters, just verify that they produce the same builder state as SetValue + assert.Equal(t, + makeKindBuilder().SetValue("my-attr", ldvalue.Bool(true)), + makeKindBuilder().SetBool("my-attr", true)) + assert.Equal(t, + makeKindBuilder().SetValue("my-attr", ldvalue.Int(100)), + makeKindBuilder().SetInt("my-attr", 100)) + assert.Equal(t, + makeKindBuilder().SetValue("my-attr", ldvalue.Float64(1.5)), + makeKindBuilder().SetFloat64("my-attr", 1.5)) + assert.Equal(t, + makeKindBuilder().SetValue("my-attr", ldvalue.String("x")), + makeKindBuilder().SetString("my-attr", "x")) }) - t.Run("Private", func(t *testing.T) { - c := NewContextBuilder().Kind("user", "my-key"). - Name("my-name"). - Private("name", "email"). - Build() + t.Run("setting to null does not add attribute", func(t *testing.T) { + assert.Equal(t, + makeKindBuilder().SetString("attr1", "value1").SetString("attr3", "value3"), + makeKindBuilder().SetString("attr1", "value1").SetValue("attr2", ldvalue.Null()).SetString("attr3", "value3")) + }) - assert.Equal(t, 2, c.PrivateAttributeCount()) - ref1, ok1 := c.PrivateAttributeByIndex(0) - ref2, ok2 := c.PrivateAttributeByIndex(1) - assert.True(t, ok1) - assert.True(t, ok2) + t.Run("setting to null removes existing attribute", func(t *testing.T) { + assert.Equal(t, + makeKindBuilder().SetString("attr1", "value1").SetString("attr3", "value3"), + makeKindBuilder().SetString("attr1", "value1").SetString("attr2", "value2").SetString("attr3", "value3"). + SetValue("attr2", ldvalue.Null())) + }) - refs := []ldattr.Ref{ref1, ref2} - assert.Contains(t, refs, ldattr.NewRef("name")) - assert.Contains(t, refs, ldattr.NewRef("email")) + t.Run("cannot add attribute with empty name", func(t *testing.T) { + assert.Equal(t, makeKindBuilder().Build(), makeKindBuilder().SetBool("", true).Build()) + assert.Equal(t, makeKindBuilder().Build(), makeKindBuilder().SetInt("", 1).Build()) + assert.Equal(t, makeKindBuilder().Build(), makeKindBuilder().SetFloat64("", 1).Build()) + assert.Equal(t, makeKindBuilder().Build(), makeKindBuilder().SetString("", "x").Build()) + assert.Equal(t, makeKindBuilder().Build(), makeKindBuilder().SetValue("", ldvalue.ArrayOf()).Build()) }) } -func TestContextBuilderErrors(t *testing.T) { - t.Run("empty builder", func(t *testing.T) { - c := NewContextBuilder().Build() - assert.True(t, c.IsDefined()) - assert.Equal(t, lderrors.ErrContextKindMultiWithNoKinds{}, c.Err()) +func TestKindBuilderSetBuiltInAttributesByName(t *testing.T) { + var boolFalse, boolTrue, stringEmpty, stringNonEmpty = ldvalue.Bool(false), ldvalue.Bool(true), + ldvalue.String("x"), ldvalue.String("") + var nullValue, intValue, floatValue, arrayValue, objectValue = ldvalue.Null(), + ldvalue.Int(1), ldvalue.Float64(1.5), ldvalue.ArrayOf(), ldvalue.ObjectBuild().Build() + + type params struct { + name string + equivalentSetter func(*KindBuilder, ldvalue.Value) + good, bad []ldvalue.Value + } + + for _, p := range []params{ + { + name: "key", + equivalentSetter: func(b *KindBuilder, v ldvalue.Value) { b.Key(v.StringValue()) }, + good: []ldvalue.Value{stringNonEmpty, stringEmpty}, + bad: []ldvalue.Value{nullValue, boolFalse, intValue, floatValue, arrayValue, objectValue}, + }, + { + name: "name", + equivalentSetter: func(b *KindBuilder, v ldvalue.Value) { b.OptName(v.AsOptionalString()) }, + good: []ldvalue.Value{stringNonEmpty, stringEmpty, nullValue}, + bad: []ldvalue.Value{boolFalse, intValue, floatValue, arrayValue, objectValue}, + }, + { + name: "anonymous", + equivalentSetter: func(b *KindBuilder, v ldvalue.Value) { b.Anonymous(v.BoolValue()) }, + good: []ldvalue.Value{boolTrue, boolFalse}, + bad: []ldvalue.Value{nullValue, intValue, floatValue, stringEmpty, stringNonEmpty, arrayValue, objectValue}, + }, + } { + t.Run(p.name, func(t *testing.T) { + builder := makeKindBuilder() // we will reuse this to prove that SetValue overwrites previous values + var lastGoodNonNullValue ldvalue.Value + + for _, goodValue := range p.good { + t.Run(fmt.Sprintf("can set to %s", goodValue.JSONString()), func(t *testing.T) { + previousState := *builder + + if !goodValue.IsNull() { + lastGoodNonNullValue = goodValue + } + expected := makeKindBuilder() + p.equivalentSetter(expected, goodValue) + + builder.SetValue(p.name, goodValue) + assert.Equal(t, expected, builder) + + b1 := previousState + assert.True(t, b1.TrySetValue(p.name, goodValue)) + assert.Equal(t, *expected, b1) + + b2 := previousState + switch goodValue.Type() { + case ldvalue.BoolType: + assert.Equal(t, expected, b2.SetBool(p.name, goodValue.BoolValue())) + case ldvalue.StringType: + assert.Equal(t, expected, b2.SetString(p.name, goodValue.StringValue())) + } + }) + } + for _, badValue := range p.bad { + t.Run(fmt.Sprintf("cannot set to %s", badValue.JSONString()), func(t *testing.T) { + startingState := func() *KindBuilder { + if lastGoodNonNullValue.IsDefined() { + return makeKindBuilder().SetValue(p.name, lastGoodNonNullValue) + } + return makeKindBuilder() + } + + assert.Equal(t, startingState(), startingState().SetValue(p.name, badValue)) + + b := startingState() + assert.False(t, b.TrySetValue(p.name, badValue)) + assert.Equal(t, startingState(), b) + + switch badValue.Type() { + case ldvalue.BoolType: + assert.Equal(t, startingState(), startingState().SetBool(p.name, badValue.BoolValue())) + case ldvalue.NumberType: + if badValue.IsInt() { + assert.Equal(t, startingState(), startingState().SetInt(p.name, badValue.IntValue())) + } else { + assert.Equal(t, startingState(), startingState().SetFloat64(p.name, badValue.Float64Value())) + } + case ldvalue.StringType: + assert.Equal(t, startingState(), makeKindBuilder().SetString(p.name, badValue.StringValue())) + } + }) + } + }) + } +} + +func TestKindBuilderSetValueCannotSetMetaProperties(t *testing.T) { + for _, p := range []struct { + name string + value ldvalue.Value + }{ + {"secondary", ldvalue.String("x")}, + {"privateAttributes", ldvalue.ArrayOf(ldvalue.String("x"))}, + } { + t.Run(p.name, func(t *testing.T) { + c := makeKindBuilder().SetValue(p.name, p.value).Build() + assert.Equal(t, p.value, c.attributes.Get(p.name)) + assert.Equal(t, ldvalue.OptionalString{}, c.secondary) + assert.Len(t, c.privateAttrs, 0) + }) + } + + t.Run("_meta", func(t *testing.T) { + b := makeKindBuilder() + assert.False(t, b.TrySetValue("_meta", ldvalue.String("hi"))) + assert.Equal(t, 0, b.Build().attributes.Count()) }) +} + +func TestKindBuilderAttributesCopyOnWrite(t *testing.T) { + value1, value2 := ldvalue.String("value1"), ldvalue.String("value2") + + b := makeKindBuilder().SetValue("attr", value1) - t.Run("duplicate kind error not applicable", func(t *testing.T) { - // With ContextBuilder, setting the same kind twice just updates it - c := NewContextBuilder(). - Kind("org", "key1"). - Kind("org", "key2"). + c1 := b.Build() + jsonhelpers.AssertEqual(t, value1, c1.attributes.Get("attr")) + + b.SetValue("attr", value2) + + c2 := b.Build() + jsonhelpers.AssertEqual(t, value2, c2.attributes.Get("attr")) + jsonhelpers.AssertEqual(t, value1, c1.attributes.Get("attr")) // unchanged +} + +func TestKindBuilderPrivate(t *testing.T) { + expectPrivateRefsToBe := func(t *testing.T, c Context, expectedRefs ...ldattr.Ref) { + if assert.Equal(t, len(expectedRefs), c.PrivateAttributeCount()) { + for i, expectedRef := range expectedRefs { + a, ok := c.PrivateAttributeByIndex(i) + assert.True(t, ok) + assert.Equal(t, expectedRef, a) + } + _, ok := c.PrivateAttributeByIndex(len(expectedRefs)) + assert.False(t, ok) + } + _, ok := c.PrivateAttributeByIndex(-1) + assert.False(t, ok) + } + + t.Run("using Refs", func(t *testing.T) { + attrRef1, attrRef2, attrRef3 := ldattr.NewRef("a"), ldattr.NewRef("/b/c"), ldattr.NewRef("d") + c := makeKindBuilder(). + PrivateRef(attrRef1, attrRef2).PrivateRef(attrRef3). Build() - assert.NoError(t, c.Err()) - assert.Equal(t, "key2", c.Key()) + + expectPrivateRefsToBe(t, c, attrRef1, attrRef2, attrRef3) }) - t.Run("error in individual contexts", func(t *testing.T) { - c := NewContextBuilder(). - Kind("kind1", ""). - Kind("kind2", "my-key"). - Kind("kind3!", "other-key"). - Build() + t.Run("using strings", func(t *testing.T) { + s1, s2, s3 := "a", "/b/c", "d" + b0 := makeKindBuilder(). + PrivateRef(ldattr.NewRef(s1), ldattr.NewRef(s2)).PrivateRef(ldattr.NewRef(s3)) + b1 := makeKindBuilder(). + Private(s1, s2, s3) + assert.Equal(t, b0, b1) + }) - assert.Error(t, c.Err()) - if assert.IsType(t, lderrors.ErrContextPerKindErrors{}, c.Err()) { - e := c.Err().(lderrors.ErrContextPerKindErrors) - assert.Len(t, e.Errors, 2) - assert.Equal(t, lderrors.ErrContextKeyEmpty{}, e.Errors["kind1"]) - assert.Equal(t, lderrors.ErrContextKindInvalidChars{}, e.Errors["kind3!"]) - } + t.Run("RemovePrivate", func(t *testing.T) { + b := makeKindBuilder().Private("a", "/b/c", "d", "/b/c") + b.RemovePrivate("/b/c") + c := b.Build() + + expectPrivateRefsToBe(t, c, ldattr.NewRef("a"), ldattr.NewRef("d")) }) -} -func TestContextBuilderChaining(t *testing.T) { - t.Run("method chaining works", func(t *testing.T) { - c := NewContextBuilder(). - Kind("user", "user-key").Name("User Name").Anonymous(true). - Kind("org", "org-key").SetString("type", "enterprise"). - Build() + t.Run("RemovePrivateRef", func(t *testing.T) { + b := makeKindBuilder().Private("a", "/b/c", "d", "/b/c") + b.RemovePrivateRef(ldattr.NewRef("/b/c")) + c := b.Build() - assert.Equal(t, 2, c.IndividualContextCount()) + expectPrivateRefsToBe(t, c, ldattr.NewRef("a"), ldattr.NewRef("d")) + }) + + t.Run("copy on write", func(t *testing.T) { + b0 := makeKindBuilder().Private("a") + + c0 := b0.Build() + expectPrivateRefsToBe(t, c0, ldattr.NewRef("a")) - userCtx := c.IndividualContextByKind("user") - assert.Equal(t, "user-key", userCtx.Key()) - assert.Equal(t, ldvalue.NewOptionalString("User Name"), userCtx.Name()) - assert.True(t, userCtx.Anonymous()) + b0.Private("b") + c1 := b0.Build() + expectPrivateRefsToBe(t, c1, ldattr.NewRef("a"), ldattr.NewRef("b")) + expectPrivateRefsToBe(t, c0, ldattr.NewRef("a")) // unchanged - orgCtx := c.IndividualContextByKind("org") - assert.Equal(t, "org-key", orgCtx.Key()) - assert.Equal(t, ldvalue.String("enterprise"), orgCtx.GetValue("type")) + b0.RemovePrivateRef(ldattr.NewRef("a")) + c2 := b0.Build() + expectPrivateRefsToBe(t, c2, ldattr.NewRef("b")) + expectPrivateRefsToBe(t, c1, ldattr.NewRef("a"), ldattr.NewRef("b")) // unchanged + expectPrivateRefsToBe(t, c0, ldattr.NewRef("a")) // unchanged }) } + +// MULTI KIND TESTS (adapted from builder_multi_test.go) + +// NEW TESTS (specific to ContextBuilder) From 364fb295d02ffe4d9d950f1f1cdadce3992bb028 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 15:15:33 -0700 Subject: [PATCH 09/20] Add multi tests --- ldcontext/builder_new_test.go | 114 +++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/ldcontext/builder_new_test.go b/ldcontext/builder_new_test.go index 3a76a46..7adba82 100644 --- a/ldcontext/builder_new_test.go +++ b/ldcontext/builder_new_test.go @@ -381,6 +381,116 @@ func TestKindBuilderPrivate(t *testing.T) { }) } -// MULTI KIND TESTS (adapted from builder_multi_test.go) +// MULTI KIND TESTS (loosely adapted from builder_multi_test.go) -// NEW TESTS (specific to ContextBuilder) +func TestContextBuilderMultiKind(t *testing.T) { + t.Run("multiple kinds", func(t *testing.T) { + b := NewContextBuilder().Kind("org", "my-org-key").Kind("user", "my-user-key") + c0 := b.Build() + + assert.True(t, c0.IsDefined()) + assert.NoError(t, c0.Err()) + assert.Equal(t, Kind("multi"), c0.Kind()) + assert.Equal(t, "", c0.Key()) + + assert.Equal(t, 2, c0.IndividualContextCount()) + }) + t.Run("updating a kind", func(t *testing.T) { + c0 := NewContextBuilder(). + Kind("org", "my-org-key"). + Name("my-org-name"). + SetString("attr", "value"). + Kind("org", "other-org-key"). + Name("other-org-name"). + SetString("foo", "bar"). + Build() + + assert.Equal(t, Kind("org"), c0.Kind()) + assert.Equal(t, "other-org-key", c0.Key()) + assert.Equal(t, ldvalue.NewOptionalString("other-org-name"), c0.Name()) + assert.Equal(t, ldvalue.String("value"), c0.GetValue("attr")) + assert.Equal(t, ldvalue.String("bar"), c0.GetValue("foo")) + }) +} + +func TestContextBuilderFullyQualifiedKey(t *testing.T) { + t.Run("kind is user", func(t *testing.T) { + c := NewContextBuilder().Kind("user", "my-user-key").Build() + assert.Equal(t, "my-user-key", c.FullyQualifiedKey()) + }) + + t.Run("kind is not user", func(t *testing.T) { + c := NewContextBuilder().Kind("org", "my-org-key").Build() + assert.Equal(t, "org:my-org-key", c.FullyQualifiedKey()) + }) + + t.Run("key is escaped", func(t *testing.T) { + c := NewContextBuilder().Kind("org", "my:key%x/y").Build() + assert.Equal(t, "org:my%3Akey%25x/y", c.FullyQualifiedKey()) + }) + + t.Run("multiple kinds", func(t *testing.T) { + c := NewContextBuilder(). + // The following ordering is deliberate because we want to verify that these items are + // sorted by kind, not by key. + Kind("kind-c", "key-1"). + Kind("kind-a", "key-2"). + Kind("kind-d", "key-3"). + Kind("kind-b", "key-4"). + Build() + assert.Equal(t, "kind-a:key-2:kind-b:key-4:kind-c:key-1:kind-d:key-3", c.FullyQualifiedKey()) + }) + + t.Run("keys are escaped", func(t *testing.T) { + c := NewContextBuilder(). + Kind("kind-a", "key-1"). + Kind("kind-b", "key:2"). + Build() + assert.Equal(t, "kind-a:key-1:kind-b:key%3A2", c.FullyQualifiedKey()) + }) +} + +func TestContextBuilderMultiKindErrors(t *testing.T) { + verifyError := func(t *testing.T, builder *ContextBuilder, expectedErr error) { + c0 := builder.Build() + assert.True(t, c0.IsDefined()) + assert.Equal(t, expectedErr, c0.Err()) + + c1, err := builder.TryBuild() + assert.True(t, c1.IsDefined()) + assert.Equal(t, expectedErr, c1.Err()) + assert.Equal(t, expectedErr, err) + } + + t.Run("empty", func(t *testing.T) { + verifyError(t, NewContextBuilder(), lderrors.ErrContextKindMultiWithNoKinds{}) + }) + + t.Run("error in individual contexts", func(t *testing.T) { + b := NewContextBuilder(). + Kind("kind1", ""). + Kind("kind2", "my-key"). + Kind("kind3!", "other-key") + + verifyError(t, b.ContextBuilder, lderrors.ErrContextPerKindErrors{ + Errors: map[string]error{ + "kind1": lderrors.ErrContextKeyEmpty{}, + "kind3!": lderrors.ErrContextKindInvalidChars{}, + }, + }) + }) +} + +func TestContextBuilderCopyOnWrite(t *testing.T) { + b := NewContextBuilder(). + Kind("org", "my-org-key"). + Kind("user", "my-user-key") + + multi1 := b.Build() + assert.Equal(t, 2, multi1.IndividualContextCount()) + + b.Kind("thing", "stuff") + multi2 := b.Build() + assert.Equal(t, 3, multi2.IndividualContextCount()) + assert.Equal(t, 2, multi1.IndividualContextCount()) // unchanged +} From e9e5f8b9d72066a5edb9547d648c42022e3f4216 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 15:26:52 -0700 Subject: [PATCH 10/20] more comment --- ldcontext/builder_new_test.go | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/ldcontext/builder_new_test.go b/ldcontext/builder_new_test.go index 7adba82..7463304 100644 --- a/ldcontext/builder_new_test.go +++ b/ldcontext/builder_new_test.go @@ -15,6 +15,9 @@ import ( // Tests are adapted from builder_simple_test.go and builder_multi_test.go. // SINGLE KIND TESTS (adapted from builder_simple_test.go) +// A lot of these tests are redundant because they test code that just calls through to a Builder anyway. +// However, duplicating the tests means we can potentially rewrite KindBuilder to not use Builder at all, then deprecate +// and remove the old Builder. func makeKindBuilder() *KindBuilder { // for test cases where the kind and key are unimportant @@ -64,23 +67,6 @@ func TestKindBuilderKeyValidation(t *testing.T) { assert.Equal(t, lderrors.ErrContextKeyEmpty{}, err) } -func TestKindBuilderFullyQualifiedKey(t *testing.T) { - t.Run("kind is user", func(t *testing.T) { - c := New("my-user-key") - assert.Equal(t, "my-user-key", c.FullyQualifiedKey()) - }) - - t.Run("kind is not user", func(t *testing.T) { - c := NewWithKind("org", "my-org-key") - assert.Equal(t, "org:my-org-key", c.FullyQualifiedKey()) - }) - - t.Run("key is escaped", func(t *testing.T) { - c := NewWithKind("org", "my:key%x/y") - assert.Equal(t, "org:my%3Akey%25x/y", c.FullyQualifiedKey()) - }) -} - func TestKindBuilderBasicSetters(t *testing.T) { t.Run("Key", func(t *testing.T) { assert.Equal(t, "other-key", makeKindBuilder().Key("other-key").Build().Key()) From 942b19e89b85a2131e795d6d4417303aaa6e11a3 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 15:29:06 -0700 Subject: [PATCH 11/20] Add test case for empty kind is user --- ldcontext/builder_new_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ldcontext/builder_new_test.go b/ldcontext/builder_new_test.go index 7463304..31af345 100644 --- a/ldcontext/builder_new_test.go +++ b/ldcontext/builder_new_test.go @@ -467,6 +467,19 @@ func TestContextBuilderMultiKindErrors(t *testing.T) { }) } +func TestContextBuilderEmptyKindIsUser(t *testing.T) { + c := NewContextBuilder(). + Kind("", "my-user-key1"). + Name("my-name"). + Kind("user", "my-user-key2"). + SetString("attr", "value"). + Build() + assert.Equal(t, "my-user-key2", c.Key()) + assert.Equal(t, ldvalue.String("value"), c.GetValue("attr")) + assert.Equal(t, ldvalue.NewOptionalString("my-name"), c.Name()) + assert.Equal(t, Kind("user"), c.Kind()) +} + func TestContextBuilderCopyOnWrite(t *testing.T) { b := NewContextBuilder(). Kind("org", "my-org-key"). From 12d4ce79176d78dbc6ab9b1b0c52301f613ba859 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 15:36:56 -0700 Subject: [PATCH 12/20] Add NewContextBuilderFromContext --- ldcontext/builder_new.go | 8 ++++++++ ldcontext/builder_new_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go index f24fac6..9f33153 100644 --- a/ldcontext/builder_new.go +++ b/ldcontext/builder_new.go @@ -107,6 +107,14 @@ func NewContextBuilder() *ContextBuilder { return &ContextBuilder{singleBuilders: make(map[Kind]*Builder)} } +func NewContextBuilderFromContext(fromContext Context) *ContextBuilder { + cb := &ContextBuilder{singleBuilders: make(map[Kind]*Builder)} + for _, c := range fromContext.GetAllIndividualContexts(nil) { + cb.singleBuilders[c.Kind()] = NewBuilderFromContext(c) + } + return cb +} + // Kind starts building a new context with the specified kind and key in the current // ContextBuilder, returning a [KindBuilder] that can set properties for that inner context. // If a context with that kind has already been defined, its key is set to the new value, diff --git a/ldcontext/builder_new_test.go b/ldcontext/builder_new_test.go index 31af345..f856678 100644 --- a/ldcontext/builder_new_test.go +++ b/ldcontext/builder_new_test.go @@ -493,3 +493,32 @@ func TestContextBuilderCopyOnWrite(t *testing.T) { assert.Equal(t, 3, multi2.IndividualContextCount()) assert.Equal(t, 2, multi1.IndividualContextCount()) // unchanged } + +func TestNewContextBuilderFromContext(t *testing.T) { + t.Run("single", func(t *testing.T) { + c := NewContextBuilder(). + Kind("org", "my-org-key"). + Name("my-org-name"). + SetString("attr", "value"). + Private("attr"). + Anonymous(true). + Build() + b := NewContextBuilderFromContext(c) + assert.Equal(t, c, b.Build()) + }) + t.Run("multi", func(t *testing.T) { + c := NewContextBuilder(). + Kind("org", "my-org-key"). + Name("my-org-name"). + SetString("attr", "value"). + Private("attr"). + Anonymous(true). + Kind("user", "my-user-key"). + Name("my-user-name"). + SetString("attr", "value"). + Private("attr"). + Build() + b := NewContextBuilderFromContext(c) + assert.Equal(t, c, b.Build()) + }) +} From c9fe673df3ab983b8994e81b7998d4eb973a0f4f Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 15:59:44 -0700 Subject: [PATCH 13/20] Deprecate MultiBuilder --- ldcontext/builder_multi.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ldcontext/builder_multi.go b/ldcontext/builder_multi.go index 2660ea3..0bb2522 100644 --- a/ldcontext/builder_multi.go +++ b/ldcontext/builder_multi.go @@ -28,6 +28,8 @@ const defaultMultiBuilderCapacity = 3 // arbitrary value based on presumed likel // A MultiBuilder should not be accessed by multiple goroutines at once. Once you have called // [MultiBuilder.Build], the resulting Context is immutable and safe to use from multiple // goroutines. +// +// Deprecated: use [ContextBuilder] instead. type MultiBuilder struct { contexts []Context contextsCopyOnWrite bool @@ -37,6 +39,8 @@ type MultiBuilder struct { // // This method is for building a [Context] that has multiple [Context.Kind] values, each with its // own nested Context. To define a single context, use [NewBuilder] instead. +// +// Deprecated: use [NewContextBuilder] instead. func NewMultiBuilder() *MultiBuilder { return &MultiBuilder{contexts: make([]Context, 0, defaultMultiBuilderCapacity)} } From 32df1c4c05fbaa506d737b899ae7b5bc1ff6c30e Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 15:59:57 -0700 Subject: [PATCH 14/20] Don't call NewBuilderFromContext in NewContextBuilderFromContext --- ldcontext/builder_new.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go index 9f33153..a13b46e 100644 --- a/ldcontext/builder_new.go +++ b/ldcontext/builder_new.go @@ -110,7 +110,9 @@ func NewContextBuilder() *ContextBuilder { func NewContextBuilderFromContext(fromContext Context) *ContextBuilder { cb := &ContextBuilder{singleBuilders: make(map[Kind]*Builder)} for _, c := range fromContext.GetAllIndividualContexts(nil) { - cb.singleBuilders[c.Kind()] = NewBuilderFromContext(c) + b := &Builder{} + b.copyFrom(fromContext) + cb.singleBuilders[c.Kind()] = b } return cb } From 760795d48c9cedb106423f85fcbcb156ad9a3432 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 16:01:41 -0700 Subject: [PATCH 15/20] Deprecate NewContextBuilder, NewContextBuilderFromContext --- ldcontext/builder_new.go | 3 ++- ldcontext/builder_simple.go | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go index a13b46e..0f7e885 100644 --- a/ldcontext/builder_new.go +++ b/ldcontext/builder_new.go @@ -141,7 +141,8 @@ func (cb *ContextBuilder) Kind(kind Kind, key string) *KindBuilder { } singleBuilder, ok := cb.singleBuilders[kind] if !ok { - singleBuilder = NewBuilder(key).Kind(kind) + b := &Builder{} + singleBuilder = b.Key(key).Kind(kind) cb.singleBuilders[kind] = singleBuilder } singleBuilder.Key(key) diff --git a/ldcontext/builder_simple.go b/ldcontext/builder_simple.go index 46f24e0..3fa3411 100644 --- a/ldcontext/builder_simple.go +++ b/ldcontext/builder_simple.go @@ -121,6 +121,8 @@ type Builder struct { // // var b ldcontext.Builder // c := b.Kind("org").Key("my-key").Name("my-name").Build() +// +// Deprecated: use [NewContextBuilder] instead. func NewBuilder(key string) *Builder { b := &Builder{} return b.Key(key) @@ -134,6 +136,8 @@ func NewBuilder(key string) *Builder { // // If fromContext is a multi-context created with [NewMulti] or [MultiBuilder], this method is // not applicable and returns an uninitialized [Builder]. +// +// Deprecated: use [NewContextBuilderFromContext] instead. func NewBuilderFromContext(fromContext Context) *Builder { b := &Builder{} b.copyFrom(fromContext) From 7a765859f035de0b4ba25f8e1b1a4b30ea1041c4 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 16:24:58 -0700 Subject: [PATCH 16/20] Add `Add` method, deprecate NewMulti --- ldcontext/builder_new.go | 17 +++++++++++++++++ ldcontext/builder_new_test.go | 25 +++++++++++++++++++++++++ ldcontext/constructors.go | 2 ++ 3 files changed, 44 insertions(+) diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go index 0f7e885..6684051 100644 --- a/ldcontext/builder_new.go +++ b/ldcontext/builder_new.go @@ -117,6 +117,23 @@ func NewContextBuilderFromContext(fromContext Context) *ContextBuilder { return cb } +// Add adds one or more contexts to a ContextBuilder. Only one context of each kind is allowed. +// If multiple contexts are added with the same kind, the last one fully replaces the previous ones. +func (cb *ContextBuilder) Add(contexts ...Context) *ContextBuilder { + for _, c := range contexts { + if c.Multiple() { + for _, ic := range c.multiContexts { + cb.Add(ic) + } + } else { + b := &Builder{} + b.copyFrom(c) + cb.singleBuilders[c.Kind()] = b + } + } + return cb +} + // Kind starts building a new context with the specified kind and key in the current // ContextBuilder, returning a [KindBuilder] that can set properties for that inner context. // If a context with that kind has already been defined, its key is set to the new value, diff --git a/ldcontext/builder_new_test.go b/ldcontext/builder_new_test.go index f856678..4c9c6c3 100644 --- a/ldcontext/builder_new_test.go +++ b/ldcontext/builder_new_test.go @@ -522,3 +522,28 @@ func TestNewContextBuilderFromContext(t *testing.T) { assert.Equal(t, c, b.Build()) }) } + +func TestContextBuilderAdd(t *testing.T) { + c1 := NewContextBuilder(). + Kind("org", "my-org-key"). + Name("my-org-name"). + Build() + c2 := NewContextBuilder(). + Kind("user", "my-user-key"). + Name("my-user-name"). + Build() + c3 := NewContextBuilder(). + Kind("thing", "my-thing-key"). + Name("my-thing-name"). + Kind("user", "other-user-key"). + SetInt("age", 42). + Build() + + c := NewContextBuilder().Add(c1, c2, c3).Build() + assert.Equal(t, 3, c.IndividualContextCount()) + assert.Equal(t, "my-org-key", c.IndividualContextKeyByKind("org")) + assert.Equal(t, "my-thing-key", c.IndividualContextKeyByKind("thing")) + assert.Equal(t, "other-user-key", c.IndividualContextKeyByKind("user")) + assert.Equal(t, ldvalue.OptionalString{}, c.IndividualContextByKind("user").Name()) + assert.Equal(t, 42, c.IndividualContextByKind("user").GetValue("age").IntValue()) +} diff --git a/ldcontext/constructors.go b/ldcontext/constructors.go index 958e7c9..1663716 100644 --- a/ldcontext/constructors.go +++ b/ldcontext/constructors.go @@ -46,6 +46,8 @@ func NewWithKind(kind Kind, key string) Context { // // c1plus2 := ldcontext.NewMulti(c1, c2) // multi2 := ldcontext.NewMulti(c1plus2, c3) +// +// Deprecated: use [NewContextBuilder] instead. func NewMulti(contexts ...Context) Context { // Same rationale as for New/NewWithKey of using the builder instead of constructing directly. var m MultiBuilder From 03dca67149f4936559acb7d64e856814b807f21b Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 16:35:29 -0700 Subject: [PATCH 17/20] Add comment --- ldcontext/builder_new.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go index 6684051..39bea69 100644 --- a/ldcontext/builder_new.go +++ b/ldcontext/builder_new.go @@ -107,6 +107,9 @@ func NewContextBuilder() *ContextBuilder { return &ContextBuilder{singleBuilders: make(map[Kind]*Builder)} } +// NewContextBuilderFromContext creates a ContextBuilder whose properties are the same as an existing +// Context. You may then change the ContextBuilder's state in any way and call [ContextBuilder.Build] +// to create a new independent [Context]. func NewContextBuilderFromContext(fromContext Context) *ContextBuilder { cb := &ContextBuilder{singleBuilders: make(map[Kind]*Builder)} for _, c := range fromContext.GetAllIndividualContexts(nil) { From 88cc8bb8b4999baf169b64f6efa303ec640a7778 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 16:35:35 -0700 Subject: [PATCH 18/20] Un-deprecate NewBuilder, NewBuilderFromContext --- ldcontext/builder_simple.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ldcontext/builder_simple.go b/ldcontext/builder_simple.go index 3fa3411..46f24e0 100644 --- a/ldcontext/builder_simple.go +++ b/ldcontext/builder_simple.go @@ -121,8 +121,6 @@ type Builder struct { // // var b ldcontext.Builder // c := b.Kind("org").Key("my-key").Name("my-name").Build() -// -// Deprecated: use [NewContextBuilder] instead. func NewBuilder(key string) *Builder { b := &Builder{} return b.Key(key) @@ -136,8 +134,6 @@ func NewBuilder(key string) *Builder { // // If fromContext is a multi-context created with [NewMulti] or [MultiBuilder], this method is // not applicable and returns an uninitialized [Builder]. -// -// Deprecated: use [NewContextBuilderFromContext] instead. func NewBuilderFromContext(fromContext Context) *Builder { b := &Builder{} b.copyFrom(fromContext) From d0713b41fff106c17b025b42b6c0bab10bfff3d7 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 16:36:31 -0700 Subject: [PATCH 19/20] lint: prealloc --- ldcontext/builder_new.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go index 39bea69..0b0e591 100644 --- a/ldcontext/builder_new.go +++ b/ldcontext/builder_new.go @@ -198,7 +198,7 @@ func (cb *ContextBuilder) Build() Context { // Sort the list by kind - this makes our output deterministic and will also be important when we // compute a fully qualified key. - var kinds []string + kinds := make([]string, 0, len(cb.singleBuilders)) for kind := range cb.singleBuilders { kinds = append(kinds, string(kind)) } From df7d1ea9ecc703e5d911d1e0b4be05fba6ebee27 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Tue, 15 Jul 2025 16:41:58 -0700 Subject: [PATCH 20/20] fix bug in NewContextBuilderFromContext --- ldcontext/builder_new.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go index 0b0e591..91efb4f 100644 --- a/ldcontext/builder_new.go +++ b/ldcontext/builder_new.go @@ -114,7 +114,7 @@ func NewContextBuilderFromContext(fromContext Context) *ContextBuilder { cb := &ContextBuilder{singleBuilders: make(map[Kind]*Builder)} for _, c := range fromContext.GetAllIndividualContexts(nil) { b := &Builder{} - b.copyFrom(fromContext) + b.copyFrom(c) cb.singleBuilders[c.Kind()] = b } return cb