diff --git a/contract-tests/client_entity.rb b/contract-tests/client_entity.rb index e47cf8d0..871c6b2b 100644 --- a/contract-tests/client_entity.rb +++ b/contract-tests/client_entity.rb @@ -144,6 +144,45 @@ def migration_operation(params) end end + def context_comparison(params) + context1 = build_context_from_params(params[:context1]) + context2 = build_context_from_params(params[:context2]) + + context1 == context2 + end + + private def build_context_from_params(params) + return build_single_context_from_attribute_definitions(params[:single]) unless params[:single].nil? + + contexts = params[:multi].map do |param| + build_single_context_from_attribute_definitions(param) + end + + LaunchDarkly::LDContext.create_multi(contexts) + end + + private def build_single_context_from_attribute_definitions(params) + context = {kind: params[:kind], key: params[:key]} + + params[:attributes]&.each do |attribute| + context[attribute[:name]] = attribute[:value] + end + + if params[:privateAttributes] + context[:_meta] = { + privateAttributes: params[:privateAttributes].map do |attribute| + if attribute[:literal] + LaunchDarkly::Reference.create_literal(attribute[:value]) + else + LaunchDarkly::Reference.create(attribute[:value]) + end + end, + } + end + + LaunchDarkly::LDContext.create(context) + end + def secure_mode_hash(params) @client.secure_mode_hash(params[:context]) end diff --git a/contract-tests/service.rb b/contract-tests/service.rb index 9e3a610a..d3c520fa 100644 --- a/contract-tests/service.rb +++ b/contract-tests/service.rb @@ -35,6 +35,7 @@ 'tags', 'migrations', 'event-sampling', + 'context-comparison', ], }.to_json end @@ -109,6 +110,9 @@ when "migrationOperation" response = {:result => client.migration_operation(params[:migrationOperation]).to_s} return [200, nil, response.to_json] + when "contextComparison" + response = {:equals => client.context_comparison(params[:contextComparison])} + return [200, nil, response.to_json] end return [400, nil, {:error => "Unknown command requested"}.to_json] diff --git a/lib/ldclient-rb/context.rb b/lib/ldclient-rb/context.rb index b4687818..a5217be9 100644 --- a/lib/ldclient-rb/context.rb +++ b/lib/ldclient-rb/context.rb @@ -47,9 +47,6 @@ class LDContext # @return [String, nil] Returns the error associated with this LDContext if invalid attr_reader :error - # @return [Array] Returns the private attributes associated with this LDContext - attr_reader :private_attributes - # # @private # @param key [String, nil] @@ -69,10 +66,10 @@ def initialize(key, fully_qualified_key, kind, name = nil, anonymous = nil, attr @name = name @anonymous = anonymous || false @attributes = attributes - @private_attributes = [] + @private_attributes = Set.new (private_attributes || []).each do |attribute| reference = Reference.create(attribute) - @private_attributes << reference if reference.error.nil? + @private_attributes.add(reference) if reference.error.nil? end @error = error @contexts = contexts @@ -80,6 +77,16 @@ def initialize(key, fully_qualified_key, kind, name = nil, anonymous = nil, attr end private_class_method :new + protected attr_reader :name, :anonymous, :attributes + + # + # @return [Array] Returns the private attributes associated with this LDContext + # + def private_attributes + # TODO(sc-227265): Return a set instead of an array. + @private_attributes.to_a + end + # # @return [Boolean] Is this LDContext a multi-kind context? # @@ -274,6 +281,33 @@ def individual_context(kind) nil end + def ==(other) + return false unless self.kind == other.kind + return false unless self.valid? == other.valid? + return false unless self.error == other.error + + return false unless self.individual_context_count == other.individual_context_count + + if self.multi_kind? + self.kinds.each do |kind| + return false unless self.individual_context(kind) == other.individual_context(kind) + end + + return true + end + + return false unless self.key == other.key + return false unless self.name == other.name + return false unless self.anonymous == other.anonymous + return false unless self.attributes == other.attributes + + # TODO(sc-227265): Calling .to_set is unnecessary once private_attributes are sets. + return false unless self.private_attributes.to_set == other.private_attributes.to_set + + true + end + alias eql? == + # # Retrieve the value of any top level, addressable attribute. # diff --git a/lib/ldclient-rb/impl/context_filter.rb b/lib/ldclient-rb/impl/context_filter.rb index 510fe28c..8ed0c19f 100644 --- a/lib/ldclient-rb/impl/context_filter.rb +++ b/lib/ldclient-rb/impl/context_filter.rb @@ -142,4 +142,4 @@ def filter(context) end end end -end \ No newline at end of file +end diff --git a/lib/ldclient-rb/reference.rb b/lib/ldclient-rb/reference.rb index 26595c74..d25ee06b 100644 --- a/lib/ldclient-rb/reference.rb +++ b/lib/ldclient-rb/reference.rb @@ -109,6 +109,8 @@ def initialize(raw_path, components = [], error = nil) end private_class_method :new + protected attr_reader :components + # # Creates a Reference from a string. For the supported syntax and examples, # see comments on the Reference type. @@ -227,6 +229,15 @@ def component(index) @components[index] end + def ==(other) + self.error == other.error && self.components == other.components + end + alias eql? == + + def hash + ([error] + components).hash + end + # # Performs unescaping of attribute reference path components: # diff --git a/spec/context_spec.rb b/spec/context_spec.rb index c9d9792c..260f0863 100644 --- a/spec/context_spec.rb +++ b/spec/context_spec.rb @@ -296,5 +296,68 @@ module LaunchDarkly end end end + + describe "equality comparisons" do + it "single kind contexts are equal" do + original_context = subject.create( + { key: 'context-key', kind: 'user', name: 'Example name', groups: ['test', 'it', 'here'], address: {street: '123 Easy St', city: 'Every Town'}, + _meta: { privateAttributes: ['name', 'out of order attribute'] } + }) + duplicate_context = subject.create( + { key: 'context-key', kind: 'user', name: 'Example name', groups: ['test', 'it', 'here'], address: {street: '123 Easy St', city: 'Every Town'}, + _meta: { privateAttributes: ['out of order attribute', 'name'] } + }) + expect(original_context).to eq(duplicate_context) + end + + it "multi kind contexts are equal" do + org_context = subject.create({ key: 'org-key', kind: 'org' }) + user_context = subject.create({ key: 'user-key', kind: 'user' }) + device_context = subject.create({ key: 'device-key', kind: 'device' }) + + original_context = subject.create_multi([org_context, user_context]) + duplicate_context = subject.create_multi([user_context, org_context]) + + expect(original_context).to eq(duplicate_context) + + superset_context = subject.create_multi([org_context, user_context, device_context]) + expect(superset_context).not_to eq(duplicate_context) + end + + it "mixed size contexts are not equal" do + org_context = subject.create({ key: 'org-key', kind: 'org' }) + user_context = subject.create({ key: 'user-key', kind: 'user' }) + + flattened_multi = subject.create_multi([org_context]) + + expect(flattened_multi).to eq(org_context) + + multi = subject.create_multi([org_context, user_context]) + expect(multi).not_to eq(org_context) + expect(multi).not_to eq(user_context) + end + + it "failed contexts can be equal" do + invalid_hash = subject.create(true) + invalid_kind = subject.create({ kind: 'this is not valid' }) + invalid_key = subject.create({ key: nil }) + invalid_name = subject.create({ key: 'user-key', name: true }) + invalid_anonymous = subject.create({ key: 'user-key', anonymous: 'this is no boolean' }) + invalid_private_attributes = subject.create({ key: 'user-key', _meta: { privateAttributes: 'this is no array' }}) + + expect(invalid_hash).not_to eq(invalid_kind) + expect(invalid_hash).not_to eq(invalid_key) + expect(invalid_hash).not_to eq(invalid_name) + expect(invalid_hash).not_to eq(invalid_anonymous) + expect(invalid_hash).not_to eq(invalid_private_attributes) + + expect(invalid_hash).to eq(subject.create(true)) + expect(invalid_kind).to eq(subject.create({ kind: 'this is not valid' })) + expect(invalid_key).to eq(subject.create({ key: nil })) + expect(invalid_name).to eq(subject.create({ key: 'user-key', name: true })) + expect(invalid_anonymous).to eq(subject.create({ key: 'user-key', anonymous: 'this is no boolean' })) + expect(invalid_private_attributes).to eq(subject.create({ key: 'user-key', _meta: { privateAttributes: 'this is no array' }})) + end + end end end diff --git a/spec/reference_spec.rb b/spec/reference_spec.rb index e1625694..4c0d0a51 100644 --- a/spec/reference_spec.rb +++ b/spec/reference_spec.rb @@ -1,112 +1,130 @@ require "ldclient-rb/reference" module LaunchDarkly -describe Reference do - subject { Reference } - - it "determines invalid formats" do - [ - # Empty reference failures - [nil, 'empty reference'], - ["", 'empty reference'], - ["/", 'empty reference'], - - # Double or trailing slashes - ["//", 'double or trailing slash'], - ["/a//b", 'double or trailing slash'], - ["/a/b/", 'double or trailing slash'], - - # Invalid escape sequence - ["/a~x", 'invalid escape sequence'], - ["/a~", 'invalid escape sequence'], - ["/a/b~x", 'invalid escape sequence'], - ["/a/b~", 'invalid escape sequence'], - - ].each do |(path, msg)| - ref = subject.create(path) - expect(ref.raw_path).to eq(path) - expect(ref.error).to eq(msg) + describe Reference do + subject { Reference } + + it "determines invalid formats" do + [ + # Empty reference failures + [nil, 'empty reference'], + ["", 'empty reference'], + ["/", 'empty reference'], + + # Double or trailing slashes + ["//", 'double or trailing slash'], + ["/a//b", 'double or trailing slash'], + ["/a/b/", 'double or trailing slash'], + + # Invalid escape sequence + ["/a~x", 'invalid escape sequence'], + ["/a~", 'invalid escape sequence'], + ["/a/b~x", 'invalid escape sequence'], + ["/a/b~", 'invalid escape sequence'], + + ].each do |(path, msg)| + ref = subject.create(path) + expect(ref.raw_path).to eq(path) + expect(ref.error).to eq(msg) + end end - end - describe "can handle valid formats" do - it "can process references without a leading slash" do - %w[key kind name name/with/slashes name~0~1with-what-looks-like-escape-sequences].each do |path| - ref = subject.create(path) + describe "can handle valid formats" do + it "can process references without a leading slash" do + %w[key kind name name/with/slashes name~0~1with-what-looks-like-escape-sequences].each do |path| + ref = subject.create(path) - expect(ref.raw_path).to eq(path) - expect(ref.error).to be_nil - expect(ref.depth).to eq(1) + expect(ref.raw_path).to eq(path) + expect(ref.error).to be_nil + expect(ref.depth).to eq(1) + end end - end - it "can handle simple references with a leading slash" do - [ - ["/key", :key], - ["/0", :"0"], - ["/name~1with~1slashes~0and~0tildes", :"name/with/slashes~and~tildes"], - ].each do |(path, component)| - ref = subject.create(path) - - expect(ref.raw_path).to eq(path) - expect(ref.error).to be_nil - expect(ref.depth).to eq(1) - expect(ref.component(0)).to eq(component) + it "can handle simple references with a leading slash" do + [ + ["/key", :key], + ["/0", :"0"], + ["/name~1with~1slashes~0and~0tildes", :"name/with/slashes~and~tildes"], + ].each do |(path, component)| + ref = subject.create(path) + + expect(ref.raw_path).to eq(path) + expect(ref.error).to be_nil + expect(ref.depth).to eq(1) + expect(ref.component(0)).to eq(component) + end end - end - it "can access sub-components of varying depths" do - [ - ["key", 1, 0, :key], - ["/key", 1, 0, :key], + it "can access sub-components of varying depths" do + [ + ["key", 1, 0, :key], + ["/key", 1, 0, :key], - ["/a/b", 2, 0, :a], - ["/a/b", 2, 1, :b], + ["/a/b", 2, 0, :a], + ["/a/b", 2, 1, :b], - ["/a~1b/c", 2, 0, :"a/b"], - ["/a~0b/c", 2, 0, :"a~b"], + ["/a~1b/c", 2, 0, :"a/b"], + ["/a~0b/c", 2, 0, :"a~b"], - ["/a/10/20/30x", 4, 1, :"10"], - ["/a/10/20/30x", 4, 2, :"20"], - ["/a/10/20/30x", 4, 3, :"30x"], + ["/a/10/20/30x", 4, 1, :"10"], + ["/a/10/20/30x", 4, 2, :"20"], + ["/a/10/20/30x", 4, 3, :"30x"], - # invalid arguments don't cause an error, they just return nil - ["", 0, 0, nil], - ["", 0, -1, nil], + # invalid arguments don't cause an error, they just return nil + ["", 0, 0, nil], + ["", 0, -1, nil], - ["key", 1, -1, nil], - ["key", 1, 1, nil], + ["key", 1, -1, nil], + ["key", 1, 1, nil], - ["/key", 1, -1, nil], - ["/key", 1, 1, nil], + ["/key", 1, -1, nil], + ["/key", 1, 1, nil], - ["/a/b", 2, -1, nil], - ["/a/b", 2, 2, nil], - ].each do |(path, depth, index, component)| - ref = subject.create(path) - expect(ref.depth).to eq(depth) - expect(ref.component(index)).to eq(component) + ["/a/b", 2, -1, nil], + ["/a/b", 2, 2, nil], + ].each do |(path, depth, index, component)| + ref = subject.create(path) + expect(ref.depth).to eq(depth) + expect(ref.component(index)).to eq(component) + end end end - end - describe "creating literal references" do - it "can create valid references" do - [ - %w[name name], - %w[a/b a/b], - %w[/a/b~c /~1a~1b~0c], - %w[/ /~1], - ].each do |(literal, path)| - expect(subject.create_literal(literal).raw_path).to eq(subject.create(path).raw_path) + describe "creating literal references" do + it "can create valid references" do + [ + %w[name name], + %w[a/b a/b], + %w[/a/b~c /~1a~1b~0c], + %w[/ /~1], + ].each do |(literal, path)| + expect(subject.create_literal(literal).raw_path).to eq(subject.create(path).raw_path) + end + end + + it("can detect invalid references") do + [nil, "", true].each do |value| + expect(subject.create_literal(value).error).to eq('empty reference') + end end end - it("can detect invalid references") do - [nil, "", true].each do |value| - expect(subject.create_literal(value).error).to eq('empty reference') + it("can handle equality comparisons") do + [ + [subject.create("name"), subject.create("name"), true], + [subject.create("name"), subject.create("/name"), true], + [subject.create("/first/name"), subject.create("/first/name"), true], + [subject.create_literal("/name"), subject.create_literal("/name"), true], + [subject.create_literal("/name"), subject.create("/~1name"), true], + [subject.create_literal("~name"), subject.create("/~0name"), true], + + [subject.create("different"), subject.create("values"), false], + [subject.create("name/"), subject.create("/name"), false], + [subject.create("/first/name"), subject.create("/first//name"), false], + + ].each do |(lhs, rhs, expectation)| + expect(lhs == rhs).to be expectation end end end end -end