featurevisor 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 984dcbada7b55c304a639cf58935c7b1dd561db2d6159b178a3829709473925a
4
- data.tar.gz: 2a4068e17a151e671b45befb743be0935b19432e91411a243e2b3f8cbfa2c1e2
3
+ metadata.gz: 42c8ec3e5bf6293eeae189365b0383588a8cf7fdd5a5ee249a6fd44a2676403c
4
+ data.tar.gz: ce5794865b3dd5a057b9e0841814dab1c276b52237f230e23da6317f8dea3f70
5
5
  SHA512:
6
- metadata.gz: b813718a6962bd78a1142e5cd7758c3a0ea176d6fc8943114bfb6c630ece7e2dc160281ec7ca661d9de012366ba7b1e3ceeff9af6f6e7bfcdc4b53d89e3ac04b
7
- data.tar.gz: 3fcc3e23b3b773c7780e5d018679c74a453e263797342b0912b02c74d634d60b4029c7e042f74225ba6550d7f293ab9819ff657f51a318f90a026b145fc896bb
6
+ metadata.gz: 41aefa056806a97bc1f5bf13d847007fd4b38fb176f97d1a09868434644fda2ed8cd58698ad9f5002a6ef5d30f7fc105accf7d505eb41fb71abe28a5bda6e47c
7
+ data.tar.gz: e4376d46699b8fe730d4dc4f73890fd43c6e879c446bc484dc6a47cc6b2c31f715af41b83b5f6189f9b8eeed3fd61b48213ec589855d909fe7ebccb702c04303
data/README.md CHANGED
@@ -41,6 +41,7 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 proje
41
41
  - [Close](#close)
42
42
  - [CLI usage](#cli-usage)
43
43
  - [Test](#test)
44
+ - [Test against local monorepo's example-1](#test-against-local-monorepos-example-1)
44
45
  - [Benchmark](#benchmark)
45
46
  - [Assess distribution](#assess-distribution)
46
47
  - [Development](#development)
@@ -49,6 +50,8 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 proje
49
50
  - [Releasing](#releasing)
50
51
  - [License](#license)
51
52
 
53
+ <!-- FEATUREVISOR_DOCS_BEGIN -->
54
+
52
55
  ## Installation
53
56
 
54
57
  Add this line to your application's Gemfile:
@@ -381,7 +384,7 @@ require 'json'
381
384
  def update_datafile(f, datafile_url)
382
385
  loop do
383
386
  sleep(5 * 60) # 5 minutes
384
-
387
+
385
388
  begin
386
389
  response = Net::HTTP.get_response(URI(datafile_url))
387
390
  datafile_content = JSON.parse(response.body)
@@ -681,10 +684,27 @@ Additional options that are available:
681
684
  ```bash
682
685
  $ bundle exec featurevisor test \
683
686
  --projectDirectoryPath="/absolute/path/to/your/featurevisor/project" \
684
- --quiet|verbose \
687
+ --quiet|--verbose \
685
688
  --onlyFailures \
686
689
  --keyPattern="myFeatureKey" \
687
- --assertionPattern="#1"
690
+ --assertionPattern="#1" \
691
+ --with-scopes \
692
+ --with-tags
693
+ ```
694
+
695
+ `--with-scopes` and `--with-tags` make the Ruby test runner build scoped/tagged datafiles in memory (via `npx featurevisor build --json`) and evaluate matching assertions against those exact datafiles.
696
+
697
+ If an assertion references `scope` and `--with-scopes` is not provided, the runner still evaluates the assertion by merging that scope's configured context into the assertion context (without building scoped datafiles).
698
+
699
+ For compatibility, camelCase aliases are also supported: `--withScopes` and `--withTags`.
700
+
701
+ ### Test against local monorepo's example-1
702
+
703
+ ```bash
704
+ $ cd /absolute/path/to/featurevisor-ruby
705
+ $ bundle exec featurevisor test --projectDirectoryPath=./monorepo/examples/example-1
706
+ $ bundle exec featurevisor test --projectDirectoryPath=./monorepo/examples/example-1 --with-scopes
707
+ $ bundle exec featurevisor test --projectDirectoryPath=./monorepo/examples/example-1 --with-tags
688
708
  ```
689
709
 
690
710
  ### Benchmark
@@ -716,6 +736,8 @@ $ bundle exec featurevisor assess-distribution \
716
736
  --n=1000
717
737
  ```
718
738
 
739
+ <!-- FEATUREVISOR_DOCS_END -->
740
+
719
741
  ## Development
720
742
 
721
743
  ### Setting up
data/bin/cli.rb CHANGED
@@ -7,7 +7,7 @@ module FeaturevisorCLI
7
7
  attr_accessor :command, :assertion_pattern, :context, :environment, :feature,
8
8
  :key_pattern, :n, :only_failures, :quiet, :variable, :variation,
9
9
  :verbose, :inflate, :show_datafile, :schema_version, :project_directory_path,
10
- :populate_uuid
10
+ :populate_uuid, :with_scopes, :with_tags
11
11
 
12
12
  def initialize
13
13
  @n = 1000
@@ -86,6 +86,14 @@ module FeaturevisorCLI
86
86
  options.schema_version = v
87
87
  end
88
88
 
89
+ opts.on("--with-scopes", "--withScopes", "Test scoped assertions against scoped datafiles") do
90
+ options.with_scopes = true
91
+ end
92
+
93
+ opts.on("--with-tags", "--withTags", "Test tagged assertions against tagged datafiles") do
94
+ options.with_tags = true
95
+ end
96
+
89
97
  opts.on("--projectDirectoryPath=PATH", "Project directory path") do |v|
90
98
  options.project_directory_path = v
91
99
  end
data/bin/commands/test.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require "json"
2
2
  require "find"
3
3
  require "open3"
4
+ require "shellwords"
4
5
 
5
6
  module FeaturevisorCLI
6
7
  module Commands
@@ -19,7 +20,7 @@ module FeaturevisorCLI
19
20
 
20
21
  # Get project configuration
21
22
  config = get_config
22
- environments = config[:environments] || []
23
+ environments = get_environments(config)
23
24
  segments_by_key = get_segments
24
25
 
25
26
  # Use CLI schemaVersion option or fallback to config
@@ -28,8 +29,8 @@ module FeaturevisorCLI
28
29
  schema_version = config[:schemaVersion]
29
30
  end
30
31
 
31
- # Build datafiles for all environments
32
- datafiles_by_environment = build_datafiles(environments, schema_version, @options.inflate)
32
+ # Build datafiles for all environments (+ scoped/tagged variants)
33
+ datafiles_by_key = build_datafiles(config, environments, schema_version, @options.inflate)
33
34
 
34
35
  puts ""
35
36
 
@@ -42,18 +43,15 @@ module FeaturevisorCLI
42
43
  return
43
44
  end
44
45
 
45
- # Create SDK instances for each environment
46
- sdk_instances_by_environment = create_sdk_instances(environments, datafiles_by_environment, level)
47
-
48
46
  # Run tests
49
- run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, segments_by_key, level)
47
+ run_tests(tests, datafiles_by_key, segments_by_key, level, config)
50
48
  end
51
49
 
52
50
  private
53
51
 
54
52
  def get_config
55
53
  puts "Getting config..."
56
- command = "(cd #{@project_path} && npx featurevisor config --json)"
54
+ command = "(cd #{Shellwords.escape(@project_path)} && npx featurevisor config --json)"
57
55
  config_output = execute_command(command)
58
56
 
59
57
  begin
@@ -67,7 +65,7 @@ module FeaturevisorCLI
67
65
 
68
66
  def get_segments
69
67
  puts "Getting segments..."
70
- command = "(cd #{@project_path} && npx featurevisor list --segments --json)"
68
+ command = "(cd #{Shellwords.escape(@project_path)} && npx featurevisor list --segments --json)"
71
69
  segments_output = execute_command(command)
72
70
 
73
71
  begin
@@ -86,40 +84,111 @@ module FeaturevisorCLI
86
84
  end
87
85
  end
88
86
 
89
- def build_datafiles(environments, schema_version, inflate)
90
- datafiles_by_environment = {}
87
+ def get_environments(config)
88
+ environments = config[:environments]
89
+
90
+ return [false] if environments == false
91
+ return environments if environments.is_a?(Array) && !environments.empty?
92
+
93
+ [false]
94
+ end
95
+
96
+ def base_datafile_key(environment)
97
+ environment == false ? false : environment
98
+ end
99
+
100
+ def scoped_datafile_key(environment, scope_name)
101
+ if environment == false
102
+ "scope-#{scope_name}"
103
+ else
104
+ "#{environment}-scope-#{scope_name}"
105
+ end
106
+ end
107
+
108
+ def tagged_datafile_key(environment, tag)
109
+ if environment == false
110
+ "tag-#{tag}"
111
+ else
112
+ "#{environment}-tag-#{tag}"
113
+ end
114
+ end
115
+
116
+ def build_datafiles(config, environments, schema_version, inflate)
117
+ datafiles_by_key = {}
91
118
 
92
119
  environments.each do |environment|
93
- puts "Building datafile for environment: #{environment}..."
120
+ environment_label = environment == false ? "default (no environment)" : environment
121
+ puts "Building datafile for environment: #{environment_label}..."
94
122
 
95
- command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"]
123
+ datafiles_by_key[base_datafile_key(environment)] = build_single_datafile(
124
+ environment: environment,
125
+ schema_version: schema_version,
126
+ inflate: inflate
127
+ )
96
128
 
97
- if schema_version && !schema_version.empty?
98
- command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--schemaVersion=#{schema_version}", "--json"]
129
+ if @options.with_scopes && config[:scopes].is_a?(Array)
130
+ config[:scopes].each do |scope|
131
+ next unless scope[:name]
132
+
133
+ puts "Building scoped datafile for scope: #{scope[:name]}..."
134
+ datafiles_by_key[scoped_datafile_key(environment, scope[:name])] = build_single_datafile(
135
+ environment: environment,
136
+ schema_version: schema_version,
137
+ inflate: inflate,
138
+ scope: scope[:name]
139
+ )
140
+ end
99
141
  end
100
142
 
101
- if inflate && inflate > 0
102
- if schema_version && !schema_version.empty?
103
- command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--schemaVersion=#{schema_version}", "--inflate=#{inflate}", "--json"]
104
- else
105
- command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--inflate=#{inflate}", "--json"]
143
+ if @options.with_tags && config[:tags].is_a?(Array)
144
+ config[:tags].each do |tag|
145
+ puts "Building tagged datafile for tag: #{tag}..."
146
+ datafiles_by_key[tagged_datafile_key(environment, tag)] = build_single_datafile(
147
+ environment: environment,
148
+ schema_version: schema_version,
149
+ inflate: inflate,
150
+ tag: tag
151
+ )
106
152
  end
107
153
  end
154
+ end
108
155
 
109
- command = command_parts.join(" ")
110
- datafile_output = execute_command(command)
156
+ datafiles_by_key
157
+ end
111
158
 
112
- begin
113
- datafile = JSON.parse(datafile_output, symbolize_names: true)
114
- datafiles_by_environment[environment] = datafile
115
- rescue JSON::ParserError => e
116
- puts "Error: Failed to parse datafile JSON for #{environment}: #{e.message}"
117
- puts "Command output: #{datafile_output}"
118
- exit 1
119
- end
159
+ def build_single_datafile(environment:, schema_version:, inflate:, scope: nil, tag: nil)
160
+ command_parts = ["npx", "featurevisor", "build", "--json"]
161
+
162
+ if environment != false && !environment.nil?
163
+ command_parts << "--environment=#{Shellwords.escape(environment.to_s)}"
164
+ end
165
+
166
+ if schema_version && !schema_version.empty?
167
+ command_parts << "--schemaVersion=#{Shellwords.escape(schema_version.to_s)}"
168
+ end
169
+
170
+ if inflate && inflate > 0
171
+ command_parts << "--inflate=#{inflate}"
172
+ end
173
+
174
+ if scope
175
+ command_parts << "--scope=#{Shellwords.escape(scope.to_s)}"
176
+ end
177
+
178
+ if tag
179
+ command_parts << "--tag=#{Shellwords.escape(tag.to_s)}"
120
180
  end
121
181
 
122
- datafiles_by_environment
182
+ command = "(cd #{Shellwords.escape(@project_path)} && #{command_parts.join(' ')})"
183
+ datafile_output = execute_command(command)
184
+
185
+ begin
186
+ JSON.parse(datafile_output, symbolize_names: true)
187
+ rescue JSON::ParserError => e
188
+ puts "Error: Failed to parse datafile JSON: #{e.message}"
189
+ puts "Command output: #{datafile_output}"
190
+ exit 1
191
+ end
123
192
  end
124
193
 
125
194
  def get_logger_level
@@ -133,17 +202,17 @@ module FeaturevisorCLI
133
202
  end
134
203
 
135
204
  def get_tests
136
- command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "list", "--tests", "--applyMatrix", "--json"]
205
+ command_parts = ["npx", "featurevisor", "list", "--tests", "--applyMatrix", "--json"]
137
206
 
138
207
  if @options.key_pattern && !@options.key_pattern.empty?
139
- command_parts << "--keyPattern=#{@options.key_pattern}"
208
+ command_parts << "--keyPattern=#{Shellwords.escape(@options.key_pattern)}"
140
209
  end
141
210
 
142
211
  if @options.assertion_pattern && !@options.assertion_pattern.empty?
143
- command_parts << "--assertionPattern=#{@options.assertion_pattern}"
212
+ command_parts << "--assertionPattern=#{Shellwords.escape(@options.assertion_pattern)}"
144
213
  end
145
214
 
146
- command = command_parts.join(" ")
215
+ command = "(cd #{Shellwords.escape(@project_path)} && #{command_parts.join(' ')})"
147
216
  tests_output = execute_command(command)
148
217
 
149
218
  begin
@@ -155,31 +224,58 @@ module FeaturevisorCLI
155
224
  end
156
225
  end
157
226
 
158
- def create_sdk_instances(environments, datafiles_by_environment, level)
159
- sdk_instances_by_environment = {}
227
+ def get_scope_context(config, scope_name)
228
+ return {} unless scope_name && config[:scopes].is_a?(Array)
160
229
 
161
- environments.each do |environment|
162
- datafile = datafiles_by_environment[environment]
163
-
164
- # Create SDK instance
165
- instance = Featurevisor.create_instance(
166
- datafile: datafile,
167
- log_level: level,
168
- hooks: [
169
- {
170
- name: "tester-hook",
171
- bucket_value: ->(options) { options.bucket_value }
172
- }
173
- ]
174
- )
230
+ scope = config[:scopes].find { |s| s[:name] == scope_name }
231
+ return {} unless scope && scope[:context].is_a?(Hash)
232
+
233
+ parse_context(scope[:context])
234
+ end
235
+
236
+ def resolve_datafile_for_assertion(assertion, datafiles_by_key)
237
+ environment = assertion.key?(:environment) ? assertion[:environment] : false
238
+ environment = false if environment.nil?
239
+
240
+ scoped_key = assertion[:scope] ? scoped_datafile_key(environment, assertion[:scope]) : nil
241
+ tagged_key = assertion[:tag] ? tagged_datafile_key(environment, assertion[:tag]) : nil
242
+ base_key = base_datafile_key(environment)
243
+
244
+ if scoped_key && datafiles_by_key.key?(scoped_key)
245
+ return datafiles_by_key[scoped_key]
246
+ end
175
247
 
176
- sdk_instances_by_environment[environment] = instance
248
+ if tagged_key && datafiles_by_key.key?(tagged_key)
249
+ return datafiles_by_key[tagged_key]
177
250
  end
178
251
 
179
- sdk_instances_by_environment
252
+ datafiles_by_key[base_key]
180
253
  end
181
254
 
182
- def run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, segments_by_key, level)
255
+ def create_tester_instance(datafile, level, assertion)
256
+ sticky = parse_sticky(assertion[:sticky])
257
+
258
+ Featurevisor.create_instance(
259
+ datafile: datafile,
260
+ sticky: sticky,
261
+ log_level: level,
262
+ hooks: [
263
+ {
264
+ name: "tester-hook",
265
+ bucket_value: ->(options) do
266
+ at = assertion[:at]
267
+ if at.is_a?(Numeric)
268
+ (at * 1000).to_i
269
+ else
270
+ options.bucket_value
271
+ end
272
+ end
273
+ }
274
+ ]
275
+ )
276
+ end
277
+
278
+ def run_tests(tests, datafiles_by_key, segments_by_key, level, config)
183
279
  passed_tests_count = 0
184
280
  failed_tests_count = 0
185
281
  passed_assertions_count = 0
@@ -197,43 +293,33 @@ module FeaturevisorCLI
197
293
  test_result = nil
198
294
 
199
295
  if test[:feature]
200
- environment = assertion[:environment]
201
- instance = sdk_instances_by_environment[environment]
202
-
203
- # Show datafile if requested
204
- if @options.show_datafile
205
- datafile = datafiles_by_environment[environment]
206
- puts ""
207
- puts JSON.pretty_generate(datafile)
208
- puts ""
296
+ datafile = resolve_datafile_for_assertion(assertion, datafiles_by_key)
297
+ if datafile.nil?
298
+ test_result = {
299
+ has_error: true,
300
+ errors: " ✘ no datafile found for assertion scope/tag/environment combination\n",
301
+ duration: 0
302
+ }
209
303
  end
210
304
 
211
- # If "at" parameter is provided, create a new instance with the specific hook
212
- if assertion[:at]
213
- datafile = datafiles_by_environment[environment]
214
-
215
- instance = Featurevisor.create_instance(
216
- datafile: datafile,
217
- log_level: level,
218
- hooks: [
219
- {
220
- name: "tester-hook",
221
- bucket_value: ->(options) do
222
- # Match JavaScript implementation: assertion.at * (MAX_BUCKETED_NUMBER / 100)
223
- # MAX_BUCKETED_NUMBER is 100000, so this becomes assertion.at * 1000
224
- at = assertion[:at]
225
- if at.is_a?(Numeric)
226
- (at * 1000).to_i
227
- else
228
- options.bucket_value
229
- end
230
- end
231
- }
232
- ]
233
- )
234
- end
305
+ if datafile
306
+ instance = create_tester_instance(datafile, level, assertion)
307
+ scope_context = {}
308
+
309
+ if assertion[:scope] && !@options.with_scopes
310
+ # If not using scoped datafiles, mimic JS behavior by merging scope context.
311
+ scope_context = get_scope_context(config, assertion[:scope])
312
+ end
313
+
314
+ # Show datafile if requested
315
+ if @options.show_datafile
316
+ puts ""
317
+ puts JSON.pretty_generate(datafile)
318
+ puts ""
319
+ end
235
320
 
236
- test_result = run_test_feature(assertion, test[:feature], instance, level)
321
+ test_result = run_test_feature(assertion, test[:feature], instance, level, scope_context)
322
+ end
237
323
  elsif test[:segment]
238
324
  segment_key = test[:segment]
239
325
  segment = segments_by_key[segment_key]
@@ -280,8 +366,9 @@ module FeaturevisorCLI
280
366
  end
281
367
  end
282
368
 
283
- def run_test_feature(assertion, feature_key, instance, level)
369
+ def run_test_feature(assertion, feature_key, instance, level, scope_context = {})
284
370
  context = parse_context(assertion[:context])
371
+ context = { **scope_context, **context } if scope_context && !scope_context.empty?
285
372
  sticky = parse_sticky(assertion[:sticky])
286
373
 
287
374
  # Set context and sticky for this assertion
@@ -325,7 +412,7 @@ module FeaturevisorCLI
325
412
  expected_variables = assertion[:expectedVariables]
326
413
  expected_variables.each do |variable_key, expected_value|
327
414
  # Set default variable value for this specific variable
328
- if assertion[:defaultVariableValues] && assertion[:defaultVariableValues][variable_key]
415
+ if assertion[:defaultVariableValues].is_a?(Hash) && assertion[:defaultVariableValues].key?(variable_key)
329
416
  override_options[:default_variable_value] = assertion[:defaultVariableValues][variable_key]
330
417
  end
331
418
 
@@ -483,7 +570,7 @@ module FeaturevisorCLI
483
570
  expected_variables = assertion[:expectedVariables]
484
571
  expected_variables.each do |variable_key, expected_value|
485
572
  # Set default variable value for this specific variable
486
- if assertion[:defaultVariableValues] && assertion[:defaultVariableValues][variable_key]
573
+ if assertion[:defaultVariableValues].is_a?(Hash) && assertion[:defaultVariableValues].key?(variable_key)
487
574
  override_options[:default_variable_value] = assertion[:defaultVariableValues][variable_key]
488
575
  end
489
576
 
@@ -522,6 +609,55 @@ module FeaturevisorCLI
522
609
  end
523
610
  end
524
611
 
612
+ # Test expectedEvaluations
613
+ if assertion[:expectedEvaluations]
614
+ expected_evaluations = assertion[:expectedEvaluations]
615
+
616
+ if expected_evaluations[:flag]
617
+ evaluation = evaluate_from_instance(instance, :flag, feature_key, context, override_options)
618
+ expected_evaluations[:flag].each do |key, expected_value|
619
+ actual_value = get_evaluation_value(evaluation, key)
620
+ if !compare_values(actual_value, expected_value)
621
+ has_error = true
622
+ errors += " ✘ expectedEvaluations.flag.#{key}: expected #{expected_value} but received #{actual_value}\n"
623
+ end
624
+ end
625
+ end
626
+
627
+ if expected_evaluations[:variation]
628
+ evaluation = evaluate_from_instance(instance, :variation, feature_key, context, override_options)
629
+ expected_evaluations[:variation].each do |key, expected_value|
630
+ actual_value = get_evaluation_value(evaluation, key)
631
+ if !compare_values(actual_value, expected_value)
632
+ has_error = true
633
+ errors += " ✘ expectedEvaluations.variation.#{key}: expected #{expected_value} but received #{actual_value}\n"
634
+ end
635
+ end
636
+ end
637
+
638
+ if expected_evaluations[:variables]
639
+ expected_evaluations[:variables].each do |variable_key, expected_eval|
640
+ if expected_eval.is_a?(Hash)
641
+ evaluation = evaluate_from_instance(
642
+ instance,
643
+ :variable,
644
+ feature_key,
645
+ context,
646
+ override_options,
647
+ variable_key
648
+ )
649
+ expected_eval.each do |key, expected_value|
650
+ actual_value = get_evaluation_value(evaluation, key)
651
+ if !compare_values(actual_value, expected_value)
652
+ has_error = true
653
+ errors += " ✘ expectedEvaluations.variables.#{variable_key}.#{key}: expected #{expected_value} but received #{actual_value}\n"
654
+ end
655
+ end
656
+ end
657
+ end
658
+ end
659
+ end
660
+
525
661
  duration = Time.now - start_time
526
662
 
527
663
  {
@@ -614,13 +750,47 @@ module FeaturevisorCLI
614
750
  def create_override_options(assertion)
615
751
  options = {}
616
752
 
617
- if assertion[:defaultVariationValue]
753
+ if assertion.key?(:defaultVariationValue)
618
754
  options[:default_variation_value] = assertion[:defaultVariationValue]
619
755
  end
620
756
 
621
757
  options
622
758
  end
623
759
 
760
+ def evaluate_from_instance(instance, type, feature_key, context, override_options, variable_key = nil)
761
+ method_name = :"evaluate_#{type}"
762
+
763
+ if instance.respond_to?(method_name)
764
+ if variable_key.nil?
765
+ return instance.send(method_name, feature_key, context, override_options)
766
+ end
767
+
768
+ return instance.send(method_name, feature_key, variable_key, context, override_options)
769
+ end
770
+
771
+ if instance.respond_to?(:parent) && instance.respond_to?(:sticky)
772
+ parent = instance.parent
773
+ combined_context = if instance.respond_to?(:context)
774
+ { **(instance.context || {}), **context }
775
+ else
776
+ context
777
+ end
778
+
779
+ combined_options = {
780
+ sticky: instance.sticky,
781
+ **override_options
782
+ }
783
+
784
+ if variable_key.nil?
785
+ return parent.send(method_name, feature_key, combined_context, combined_options)
786
+ end
787
+
788
+ return parent.send(method_name, feature_key, variable_key, combined_context, combined_options)
789
+ end
790
+
791
+ {}
792
+ end
793
+
624
794
  def get_evaluation_value(evaluation, key)
625
795
  case key
626
796
  when :type
@@ -659,6 +829,8 @@ module FeaturevisorCLI
659
829
  evaluation[:variable_value]
660
830
  when :variableSchema
661
831
  evaluation[:variable_schema]
832
+ when :variableOverrideIndex
833
+ evaluation[:variable_override_index]
662
834
  else
663
835
  nil
664
836
  end
@@ -17,7 +17,8 @@ module Featurevisor
17
17
  VARIABLE_NOT_FOUND = "variable_not_found" # variable's schema is not defined in the feature
18
18
  VARIABLE_DEFAULT = "variable_default" # default variable value used
19
19
  VARIABLE_DISABLED = "variable_disabled" # feature is disabled, and variable's disabledValue is used
20
- VARIABLE_OVERRIDE = "variable_override" # variable overridden from inside a variation
20
+ VARIABLE_OVERRIDE_VARIATION = "variable_override_variation" # variable overridden from inside a variation
21
+ VARIABLE_OVERRIDE_RULE = "variable_override_rule" # variable overridden from inside a rule
21
22
 
22
23
  # Common
23
24
  NO_MATCH = "no_match" # no rules matched
@@ -55,14 +56,14 @@ module Featurevisor
55
56
  evaluation = evaluate(result_options)
56
57
 
57
58
  # Default: variation
58
- if options[:default_variation_value] &&
59
+ if options.key?(:default_variation_value) &&
59
60
  evaluation[:type] == "variation" &&
60
61
  evaluation[:variation_value].nil?
61
62
  evaluation[:variation_value] = options[:default_variation_value]
62
63
  end
63
64
 
64
65
  # Default: variable
65
- if options[:default_variable_value] &&
66
+ if options.key?(:default_variable_value) &&
66
67
  evaluation[:type] == "variable" &&
67
68
  evaluation[:variable_value].nil?
68
69
  evaluation[:variable_value] = options[:default_variable_value]
@@ -132,10 +133,10 @@ module Featurevisor
132
133
  if type == "variable"
133
134
  if feature && variable_key &&
134
135
  feature[:variablesSchema] &&
135
- (feature[:variablesSchema][variable_key] || feature[:variablesSchema][variable_key.to_sym])
136
- variable_schema = feature[:variablesSchema][variable_key] || feature[:variablesSchema][variable_key.to_sym]
136
+ has_key?(feature[:variablesSchema], variable_key)
137
+ variable_schema = fetch_with_symbol_key(feature[:variablesSchema], variable_key)
137
138
 
138
- if variable_schema[:disabledValue]
139
+ if variable_schema.key?(:disabledValue)
139
140
  # disabledValue: <value>
140
141
  evaluation = {
141
142
  type: type,
@@ -179,8 +180,8 @@ module Featurevisor
179
180
  end
180
181
 
181
182
  # Sticky
182
- if sticky && (sticky[feature_key] || sticky[feature_key.to_sym])
183
- sticky_feature = sticky[feature_key] || sticky[feature_key.to_sym]
183
+ if sticky && has_key?(sticky, feature_key)
184
+ sticky_feature = fetch_with_symbol_key(sticky, feature_key)
184
185
 
185
186
  # flag
186
187
  if type == "flag" && sticky_feature.key?(:enabled)
@@ -201,7 +202,7 @@ module Featurevisor
201
202
  if type == "variation"
202
203
  variation_value = sticky_feature[:variation]
203
204
 
204
- if variation_value
205
+ unless variation_value.nil?
205
206
  evaluation = {
206
207
  type: type,
207
208
  feature_key: feature_key,
@@ -219,8 +220,8 @@ module Featurevisor
219
220
  if type == "variable" && variable_key
220
221
  variables = sticky_feature[:variables]
221
222
 
222
- if variables && (variables[variable_key] || variables[variable_key.to_sym])
223
- variable_value = variables[variable_key] || variables[variable_key.to_sym]
223
+ if variables && has_key?(variables, variable_key)
224
+ variable_value = fetch_with_symbol_key(variables, variable_key)
224
225
  evaluation = {
225
226
  type: type,
226
227
  feature_key: feature_key,
@@ -261,8 +262,8 @@ module Featurevisor
261
262
  variable_schema = nil
262
263
 
263
264
  if variable_key
264
- if feature[:variablesSchema] && (feature[:variablesSchema][variable_key] || feature[:variablesSchema][variable_key.to_sym])
265
- variable_schema = feature[:variablesSchema][variable_key] || feature[:variablesSchema][variable_key.to_sym]
265
+ if feature[:variablesSchema] && has_key?(feature[:variablesSchema], variable_key)
266
+ variable_schema = fetch_with_symbol_key(feature[:variablesSchema], variable_key)
266
267
  end
267
268
 
268
269
  # variable schema not found
@@ -343,8 +344,8 @@ module Featurevisor
343
344
  end
344
345
 
345
346
  # variable
346
- if variable_key && force[:variables] && (force[:variables][variable_key] || force[:variables][variable_key.to_sym])
347
- variable_value = force[:variables][variable_key] || force[:variables][variable_key.to_sym]
347
+ if variable_key && force[:variables] && has_key?(force[:variables], variable_key)
348
+ variable_value = fetch_with_symbol_key(force[:variables], variable_key)
348
349
  evaluation = {
349
350
  type: type,
350
351
  feature_key: feature_key,
@@ -385,8 +386,8 @@ module Featurevisor
385
386
 
386
387
  required_variation_value = nil
387
388
 
388
- if required_variation_evaluation[:variation_value]
389
- required_variation_value = required_variation_evaluation[:variation_value]
389
+ if has_key?(required_variation_evaluation, :variation_value)
390
+ required_variation_value = fetch_with_symbol_key(required_variation_evaluation, :variation_value)
390
391
  elsif required_variation_evaluation[:variation]
391
392
  required_variation_value = required_variation_evaluation[:variation][:value]
392
393
  end
@@ -601,10 +602,50 @@ module Featurevisor
601
602
  # variable
602
603
  if type == "variable" && variable_key
603
604
  # override from rule
605
+ if matched_traffic &&
606
+ matched_traffic[:variableOverrides] &&
607
+ has_key?(matched_traffic[:variableOverrides], variable_key)
608
+ overrides = fetch_with_symbol_key(matched_traffic[:variableOverrides], variable_key)
609
+
610
+ override_index = overrides.find_index do |o|
611
+ if o[:conditions]
612
+ conditions = o[:conditions].is_a?(String) && o[:conditions] != "*" ? JSON.parse(o[:conditions]) : o[:conditions]
613
+ datafile_reader.all_conditions_are_matched(conditions, context)
614
+ elsif o[:segments]
615
+ segments = datafile_reader.parse_segments_if_stringified(o[:segments])
616
+ datafile_reader.all_segments_are_matched(segments, context)
617
+ else
618
+ false
619
+ end
620
+ end
621
+
622
+ unless override_index.nil?
623
+ override = overrides[override_index]
624
+
625
+ evaluation = {
626
+ type: type,
627
+ feature_key: feature_key,
628
+ reason: Featurevisor::EvaluationReason::VARIABLE_OVERRIDE_RULE,
629
+ bucket_key: bucket_key,
630
+ bucket_value: bucket_value,
631
+ rule_key: matched_traffic[:key],
632
+ traffic: matched_traffic,
633
+ variable_key: variable_key,
634
+ variable_schema: variable_schema,
635
+ variable_value: override[:value],
636
+ variable_override_index: override_index
637
+ }
638
+
639
+ logger.debug("variable override from rule", evaluation)
640
+
641
+ return evaluation
642
+ end
643
+ end
644
+
604
645
  if matched_traffic &&
605
646
  matched_traffic[:variables] &&
606
- (matched_traffic[:variables][variable_key] || matched_traffic[:variables][variable_key.to_sym])
607
- variable_value = matched_traffic[:variables][variable_key] || matched_traffic[:variables][variable_key.to_sym]
647
+ has_key?(matched_traffic[:variables], variable_key)
648
+ variable_value = fetch_with_symbol_key(matched_traffic[:variables], variable_key)
608
649
  evaluation = {
609
650
  type: type,
610
651
  feature_key: feature_key,
@@ -637,81 +678,38 @@ module Featurevisor
637
678
  if variation_value && feature[:variations].is_a?(Array)
638
679
  variation = feature[:variations].find { |v| v[:value] == variation_value }
639
680
 
640
- if variation && variation[:variableOverrides] && (variation[:variableOverrides][variable_key] || variation[:variableOverrides][variable_key.to_sym])
641
- overrides = variation[:variableOverrides][variable_key] || variation[:variableOverrides][variable_key.to_sym]
681
+ if variation && variation[:variableOverrides] && has_key?(variation[:variableOverrides], variable_key)
682
+ overrides = fetch_with_symbol_key(variation[:variableOverrides], variable_key)
642
683
 
643
- logger.debug("checking variableOverrides", {
644
- feature_key: feature_key,
645
- variable_key: variable_key,
646
- overrides: overrides,
647
- context: context
648
- })
649
-
650
- override = overrides.find do |o|
651
- logger.debug("evaluating override", {
652
- feature_key: feature_key,
653
- variable_key: variable_key,
654
- override: o,
655
- context: context
656
- })
657
-
658
- result = if o[:conditions]
659
- matched = datafile_reader.all_conditions_are_matched(
660
- o[:conditions].is_a?(String) && o[:conditions] != "*" ?
661
- JSON.parse(o[:conditions]) : o[:conditions],
662
- context
663
- )
664
- logger.debug("conditions match result", {
665
- feature_key: feature_key,
666
- variable_key: variable_key,
667
- conditions: o[:conditions],
668
- matched: matched
669
- })
670
- matched
684
+ override_index = overrides.find_index do |o|
685
+ if o[:conditions]
686
+ conditions = o[:conditions].is_a?(String) && o[:conditions] != "*" ? JSON.parse(o[:conditions]) : o[:conditions]
687
+ datafile_reader.all_conditions_are_matched(conditions, context)
671
688
  elsif o[:segments]
672
689
  segments = datafile_reader.parse_segments_if_stringified(o[:segments])
673
- matched = datafile_reader.all_segments_are_matched(segments, context)
674
- logger.debug("segments match result", {
675
- feature_key: feature_key,
676
- variable_key: variable_key,
677
- segments: o[:segments],
678
- parsed_segments: segments,
679
- matched: matched
680
- })
681
- matched
690
+ datafile_reader.all_segments_are_matched(segments, context)
682
691
  else
683
- logger.debug("override has no conditions or segments", {
684
- feature_key: feature_key,
685
- variable_key: variable_key,
686
- override: o
687
- })
688
692
  false
689
693
  end
690
-
691
- logger.debug("override evaluation result", {
692
- feature_key: feature_key,
693
- variable_key: variable_key,
694
- result: result
695
- })
696
-
697
- result
698
694
  end
699
695
 
700
- if override
696
+ unless override_index.nil?
697
+ override = overrides[override_index]
701
698
  evaluation = {
702
699
  type: type,
703
700
  feature_key: feature_key,
704
- reason: Featurevisor::EvaluationReason::VARIABLE_OVERRIDE,
701
+ reason: Featurevisor::EvaluationReason::VARIABLE_OVERRIDE_VARIATION,
705
702
  bucket_key: bucket_key,
706
703
  bucket_value: bucket_value,
707
704
  rule_key: matched_traffic&.[](:key),
708
705
  traffic: matched_traffic,
709
706
  variable_key: variable_key,
710
707
  variable_schema: variable_schema,
711
- variable_value: override[:value]
708
+ variable_value: override[:value],
709
+ variable_override_index: override_index
712
710
  }
713
711
 
714
- logger.debug("variable override", evaluation)
712
+ logger.debug("variable override from variation", evaluation)
715
713
 
716
714
  return evaluation
717
715
  end
@@ -719,8 +717,8 @@ module Featurevisor
719
717
 
720
718
  if variation &&
721
719
  variation[:variables] &&
722
- (variation[:variables][variable_key] || variation[:variables][variable_key.to_sym])
723
- variable_value = variation[:variables][variable_key] || variation[:variables][variable_key.to_sym]
720
+ has_key?(variation[:variables], variable_key)
721
+ variable_value = fetch_with_symbol_key(variation[:variables], variable_key)
724
722
  evaluation = {
725
723
  type: type,
726
724
  feature_key: feature_key,
@@ -814,5 +812,20 @@ module Featurevisor
814
812
  evaluation
815
813
  end
816
814
  end
815
+
816
+ def self.fetch_with_symbol_key(obj, key)
817
+ return obj[key] if obj.is_a?(Hash) && obj.key?(key)
818
+
819
+ symbol_key = key.to_sym
820
+ return obj[symbol_key] if obj.is_a?(Hash) && obj.key?(symbol_key)
821
+
822
+ nil
823
+ end
824
+
825
+ def self.has_key?(obj, key)
826
+ return false unless obj.is_a?(Hash)
827
+
828
+ obj.key?(key) || obj.key?(key.to_sym)
829
+ end
817
830
  end
818
831
  end
@@ -1,3 +1,3 @@
1
1
  module Featurevisor
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featurevisor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fahad Heylaal