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)} } diff --git a/ldcontext/builder_new.go b/ldcontext/builder_new.go new file mode 100644 index 0000000..91efb4f --- /dev/null +++ b/ldcontext/builder_new.go @@ -0,0 +1,499 @@ +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" +) + +// 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)} +} + +// 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) { + b := &Builder{} + b.copyFrom(c) + cb.singleBuilders[c.Kind()] = b + } + 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, +// 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 + } + singleBuilder, ok := cb.singleBuilders[kind] + if !ok { + b := &Builder{} + singleBuilder = b.Key(key).Kind(kind) + cb.singleBuilders[kind] = singleBuilder + } + singleBuilder.Key(key) + 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{}} + } + + 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. + kinds := make([]string, 0, len(cb.singleBuilders)) + for kind := range cb.singleBuilders { + kinds = append(kinds, string(kind)) + } + sort.Strings(kinds) + + ret := Context{ + defined: true, + 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 { + 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 + // 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 +} + +// 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 { + _ = kb.TrySetValue(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 { + // 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 +// 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 +} diff --git a/ldcontext/builder_new_test.go b/ldcontext/builder_new_test.go new file mode 100644 index 0000000..4c9c6c3 --- /dev/null +++ b/ldcontext/builder_new_test.go @@ -0,0 +1,549 @@ +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/launchdarkly/go-test-helpers/v3/jsonhelpers" +) + +// 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 + return NewContextBuilder().Kind("user", "my-key") +} + +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 TestKindBuilderKindValidation(t *testing.T) { + for _, p := range makeInvalidKindTestParams() { + t.Run(p.kind, func(t *testing.T) { + 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 TestKindBuilderKeyValidation(t *testing.T) { + b := NewContextBuilder().Kind("user", "") + + 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 TestKindBuilderBasicSetters(t *testing.T) { + t.Run("Key", func(t *testing.T) { + assert.Equal(t, "other-key", makeKindBuilder().Key("other-key").Build().Key()) + }) + + t.Run("Name", func(t *testing.T) { + 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) { + c0 := makeKindBuilder().Build() + assert.False(t, c0.Anonymous()) + + c1 := makeKindBuilder().Anonymous(false).Build() + assert.False(t, c1.Anonymous()) + + c2 := makeKindBuilder().Anonymous(true).Build() + assert.True(t, c2.Anonymous()) + }) +} + +func TestKindBuilderSetCustomAttributes(t *testing.T) { + t.Run("SetValue", func(t *testing.T) { + 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) { + // 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("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")) + }) + + 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())) + }) + + 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 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) + + 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() + + expectPrivateRefsToBe(t, c, attrRef1, attrRef2, attrRef3) + }) + + 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) + }) + + 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")) + }) + + 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() + + 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")) + + b0.Private("b") + c1 := b0.Build() + expectPrivateRefsToBe(t, c1, ldattr.NewRef("a"), ldattr.NewRef("b")) + expectPrivateRefsToBe(t, c0, ldattr.NewRef("a")) // unchanged + + 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 (loosely adapted from builder_multi_test.go) + +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 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"). + 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 +} + +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()) + }) +} + +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