featurevisor 1.1.0 → 3.0.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.
@@ -1,309 +1,126 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Featurevisor
4
- # Child instance class for managing child contexts and sticky features
4
+ # Child instance with isolated context and sticky state.
5
5
  class ChildInstance
6
- # Initialize a new child instance
7
- # @param options [Hash] Child instance options
8
- # @option options [Instance] :parent Parent instance
9
- # @option options [Hash] :context Child context
10
- # @option options [Hash] :sticky Child sticky features
11
6
  def initialize(options)
12
7
  @parent = options[:parent]
13
8
  @context = options[:context] || {}
14
- @sticky = options[:sticky] || {}
9
+ @sticky_features = options[:sticky_features] || {}
10
+ @sticky_variables = options[:sticky_variables] || {}
15
11
  @emitter = Featurevisor::Emitter.new
12
+ @parent_unsubscribers = []
16
13
  end
17
14
 
18
- # Subscribe to an event
19
- # @param event_name [String] Event name
20
- # @param callback [Proc] Callback function
21
- # @return [Proc] Unsubscribe function
22
15
  def on(event_name, callback = nil, &block)
23
16
  callback = block if block_given?
24
-
25
- if event_name == "context_set" || event_name == "sticky_set"
26
- @emitter.on(event_name, callback)
27
- else
28
- @parent.on(event_name, callback)
17
+ if %w[context_set sticky_features_set sticky_variables_set].include?(event_name)
18
+ return @emitter.on(event_name, callback)
19
+ end
20
+
21
+ parent_unsubscribe = @parent.on(event_name, callback)
22
+ active = true
23
+ unsubscribe = nil
24
+ unsubscribe = proc do
25
+ next unless active
26
+
27
+ active = false
28
+ parent_unsubscribe.call
29
+ @parent_unsubscribers.delete(unsubscribe)
29
30
  end
31
+ @parent_unsubscribers << unsubscribe
32
+ unsubscribe
30
33
  end
31
34
 
32
- # Close the child instance
33
35
  def close
36
+ @parent_unsubscribers.dup.each(&:call)
37
+ @parent_unsubscribers.clear
34
38
  @emitter.clear_all
35
39
  end
36
40
 
37
- # Set context
38
- # @param context [Hash] Context to set
39
- # @param replace [Boolean] Whether to replace existing context
40
41
  def set_context(context, replace = false)
41
- if replace
42
- @context = context
43
- else
44
- @context = { **@context, **context }
45
- end
46
-
47
- @emitter.trigger("context_set", {
48
- context: @context,
49
- replaced: replace
50
- })
42
+ @context = replace ? context : { **@context, **context }
43
+ @emitter.trigger("context_set", context: @context, replaced: replace)
51
44
  end
52
45
 
53
- # Get context
54
- # @param context [Hash, nil] Additional context to merge
55
- # @return [Hash] Merged context
56
46
  def get_context(context = nil)
57
- @parent.get_context({
58
- **@context,
59
- **(context || {})
60
- })
47
+ @parent.get_context({ **@context, **(context || {}) })
61
48
  end
62
49
 
63
- # Set sticky features
64
- # @param sticky [Hash] Sticky features
65
- # @param replace [Boolean] Whether to replace existing sticky features
66
- def set_sticky(sticky, replace = false)
67
- previous_sticky_features = @sticky || {}
68
-
69
- if replace
70
- @sticky = sticky
71
- else
72
- @sticky = {
73
- **@sticky,
74
- **sticky
75
- }
76
- end
50
+ def set_sticky_features(sticky, replace = false)
51
+ previous = @sticky_features
52
+ @sticky_features = replace ? sticky : { **@sticky_features, **sticky }
53
+ params = Featurevisor::Events.get_params_for_sticky_features_set_event(previous, @sticky_features, replace)
54
+ @emitter.trigger("sticky_features_set", params)
55
+ end
77
56
 
78
- params = Featurevisor::Events.get_params_for_sticky_set_event(previous_sticky_features, @sticky, replace)
79
- @emitter.trigger("sticky_set", params)
57
+ def set_sticky_variables(sticky, replace = false)
58
+ previous = @sticky_variables
59
+ @sticky_variables = replace ? sticky : { **@sticky_variables, **sticky }
60
+ @emitter.trigger(
61
+ "sticky_variables_set",
62
+ Featurevisor::Events.get_params_for_sticky_variables_set_event(previous, @sticky_variables, replace)
63
+ )
80
64
  end
81
65
 
82
- # Check if a feature is enabled
83
- # @param feature_key [String] Feature key
84
- # @param context [Hash] Context
85
- # @param options [Hash] Override options
86
- # @return [Boolean] True if feature is enabled
87
66
  def is_enabled(feature_key, context = {}, options = {})
88
- @parent.is_enabled(
89
- feature_key,
90
- {
91
- **@context,
92
- **context
93
- },
94
- {
95
- **options,
96
- __featurevisor_child_sticky: @sticky
97
- }
98
- )
67
+ @parent.is_enabled(feature_key, child_context(context), child_options(options))
99
68
  end
100
69
 
101
- # Get variation value
102
- # @param feature_key [String] Feature key
103
- # @param context [Hash] Context
104
- # @param options [Hash] Override options
105
- # @return [String, nil] Variation value or nil
106
- def get_variation(feature_key, context = {}, options = {})
107
- @parent.get_variation(
108
- feature_key,
109
- {
110
- **@context,
111
- **context
112
- },
113
- {
114
- **options,
115
- __featurevisor_child_sticky: @sticky
116
- }
117
- )
70
+ def evaluate_flag(feature_key, context = {}, options = {})
71
+ @parent.evaluate_flag(feature_key, child_context(context), child_options(options))
118
72
  end
119
73
 
120
- # Get variable value
121
- # @param feature_key [String] Feature key
122
- # @param variable_key [String] Variable key
123
- # @param context [Hash] Context
124
- # @param options [Hash] Override options
125
- # @return [Object, nil] Variable value or nil
126
- def get_variable(feature_key, variable_key, context = {}, options = {})
127
- @parent.get_variable(
128
- feature_key,
129
- variable_key,
130
- {
131
- **@context,
132
- **context
133
- },
134
- {
135
- **options,
136
- __featurevisor_child_sticky: @sticky
137
- }
138
- )
74
+ def get_variation(feature_key, context = {}, options = {})
75
+ @parent.get_variation(feature_key, child_context(context), child_options(options))
139
76
  end
140
77
 
141
- # Get variable as boolean
142
- # @param feature_key [String] Feature key
143
- # @param variable_key [String] Variable key
144
- # @param context [Hash] Context
145
- # @param options [Hash] Override options
146
- # @return [Boolean, nil] Boolean value or nil
147
- def get_variable_boolean(feature_key, variable_key, context = {}, options = {})
148
- @parent.get_variable_boolean(
149
- feature_key,
150
- variable_key,
151
- {
152
- **@context,
153
- **context
154
- },
155
- {
156
- **options,
157
- __featurevisor_child_sticky: @sticky
158
- }
159
- )
78
+ def evaluate_variation(feature_key, context = {}, options = {})
79
+ @parent.evaluate_variation(feature_key, child_context(context), child_options(options))
160
80
  end
161
81
 
162
- # Get variable as string
163
- # @param feature_key [String] Feature key
164
- # @param variable_key [String] Variable key
165
- # @param context [Hash] Context
166
- # @param options [Hash] Override options
167
- # @return [String, nil] String value or nil
168
- def get_variable_string(feature_key, variable_key, context = {}, options = {})
169
- @parent.get_variable_string(
170
- feature_key,
171
- variable_key,
172
- {
173
- **@context,
174
- **context
175
- },
176
- {
177
- **options,
178
- __featurevisor_child_sticky: @sticky
179
- }
180
- )
82
+ def get_variable(feature_or_variable_key, variable_key_or_context = nil, context_or_options = {}, options = {})
83
+ delegate_variable(:get_variable, feature_or_variable_key, variable_key_or_context, context_or_options, options)
181
84
  end
182
85
 
183
- # Get variable as integer
184
- # @param feature_key [String] Feature key
185
- # @param variable_key [String] Variable key
186
- # @param context [Hash] Context
187
- # @param options [Hash] Override options
188
- # @return [Integer, nil] Integer value or nil
189
- def get_variable_integer(feature_key, variable_key, context = {}, options = {})
190
- @parent.get_variable_integer(
191
- feature_key,
192
- variable_key,
193
- {
194
- **@context,
195
- **context
196
- },
197
- {
198
- **options,
199
- __featurevisor_child_sticky: @sticky
200
- }
201
- )
86
+ def evaluate_variable(feature_or_variable_key, variable_key_or_context = nil, context_or_options = {}, options = {})
87
+ delegate_variable(:evaluate_variable, feature_or_variable_key, variable_key_or_context, context_or_options, options)
202
88
  end
203
89
 
204
- # Get variable as double
205
- # @param feature_key [String] Feature key
206
- # @param variable_key [String] Variable key
207
- # @param context [Hash] Context
208
- # @param options [Hash] Override options
209
- # @return [Float, nil] Float value or nil
210
- def get_variable_double(feature_key, variable_key, context = {}, options = {})
211
- @parent.get_variable_double(
212
- feature_key,
213
- variable_key,
214
- {
215
- **@context,
216
- **context
217
- },
218
- {
219
- **options,
220
- __featurevisor_child_sticky: @sticky
221
- }
222
- )
90
+ %i[boolean string integer double array object json].each do |type|
91
+ define_method("get_variable_#{type}") do |feature_or_variable_key, variable_key_or_context = nil, context_or_options = {}, options = {}|
92
+ delegate_variable("get_variable_#{type}".to_sym, feature_or_variable_key, variable_key_or_context, context_or_options, options)
93
+ end
223
94
  end
224
95
 
225
- # Get variable as array
226
- # @param feature_key [String] Feature key
227
- # @param variable_key [String] Variable key
228
- # @param context [Hash] Context
229
- # @param options [Hash] Override options
230
- # @return [Array, nil] Array value or nil
231
- def get_variable_array(feature_key, variable_key, context = {}, options = {})
232
- @parent.get_variable_array(
233
- feature_key,
234
- variable_key,
235
- {
236
- **@context,
237
- **context
238
- },
239
- {
240
- **options,
241
- __featurevisor_child_sticky: @sticky
242
- }
243
- )
96
+ def get_feature_evaluations(context = {}, feature_keys = [], options = {})
97
+ @parent.get_feature_evaluations(child_context(context), feature_keys, child_options(options))
244
98
  end
245
99
 
246
- # Get variable as object
247
- # @param feature_key [String] Feature key
248
- # @param variable_key [String] Variable key
249
- # @param context [Hash] Context
250
- # @param options [Hash] Override options
251
- # @return [Hash, nil] Object value or nil
252
- def get_variable_object(feature_key, variable_key, context = {}, options = {})
253
- @parent.get_variable_object(
254
- feature_key,
255
- variable_key,
256
- {
257
- **@context,
258
- **context
259
- },
260
- {
261
- **options,
262
- __featurevisor_child_sticky: @sticky
263
- }
264
- )
100
+ def get_variable_evaluations(context = {}, variable_keys = [], options = {})
101
+ @parent.get_variable_evaluations(child_context(context), variable_keys, child_options(options))
265
102
  end
266
103
 
267
- # Get variable as JSON
268
- # @param feature_key [String] Feature key
269
- # @param variable_key [String] Variable key
270
- # @param context [Hash] Context
271
- # @param options [Hash] Override options
272
- # @return [Object, nil] JSON value or nil
273
- def get_variable_json(feature_key, variable_key, context = {}, options = {})
274
- @parent.get_variable_json(
275
- feature_key,
276
- variable_key,
277
- {
278
- **@context,
279
- **context
280
- },
281
- {
282
- **options,
283
- __featurevisor_child_sticky: @sticky
284
- }
285
- )
104
+ private
105
+
106
+ def child_context(context)
107
+ { **@context, **context }
286
108
  end
287
109
 
288
- # Get all evaluations
289
- # @param context [Hash] Context
290
- # @param feature_keys [Array<String>] Feature keys to evaluate
291
- # @param options [Hash] Override options
292
- # @return [Hash] All evaluations
293
- def get_all_evaluations(context = {}, feature_keys = [], options = {})
294
- @parent.get_all_evaluations(
295
- {
296
- **@context,
297
- **context
298
- },
299
- feature_keys,
300
- {
301
- **options,
302
- __featurevisor_child_sticky: @sticky
303
- }
304
- )
110
+ def child_options(options)
111
+ {
112
+ **options,
113
+ __featurevisor_child_sticky_features: @sticky_features,
114
+ __featurevisor_child_sticky_variables: @sticky_variables
115
+ }
305
116
  end
306
117
 
307
- private
118
+ def delegate_variable(method, first, second, third, fourth)
119
+ if second.nil? || second.is_a?(Hash)
120
+ @parent.public_send(method, first, child_context(second || {}), child_options(third))
121
+ else
122
+ @parent.public_send(method, first, second, child_context(third), child_options(fourth))
123
+ end
124
+ end
308
125
  end
309
126
  end
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "date"
4
+ require "time"
4
5
 
5
6
  module Featurevisor
6
7
  # Conditions module for evaluating feature flags and segments
7
8
  module Conditions
9
+ MISSING = Object.new.freeze
10
+ private_constant :MISSING
11
+
8
12
  # Get value from context object using dot notation path
9
13
  # @param obj [Hash] Context object
10
14
  # @param path [String] Dot-separated path to the value
@@ -12,12 +16,36 @@ module Featurevisor
12
16
  def self.get_value_from_context(obj, path)
13
17
  return nil if obj.nil? || path.nil?
14
18
 
15
- if path.index(".") == -1
16
- return obj[path.to_sym] || obj[path]
19
+ value = get_value_with_presence(obj, path)
20
+ value.equal?(MISSING) ? nil : value
21
+ end
22
+
23
+ def self.get_value_with_presence(obj, path)
24
+ return MISSING unless obj.is_a?(Hash) && path.is_a?(String)
25
+
26
+ path.split(".").reduce(obj) do |current, key|
27
+ break MISSING unless current.is_a?(Hash)
28
+
29
+ if current.key?(key.to_sym)
30
+ current[key.to_sym]
31
+ elsif current.key?(key)
32
+ current[key]
33
+ else
34
+ break MISSING
35
+ end
17
36
  end
37
+ end
38
+ private_class_method :get_value_with_presence
39
+
40
+ def self.strict_equal?(left, right)
41
+ return true if left.nil? && right.nil?
42
+ return left.to_f == right.to_f if left.is_a?(Numeric) && right.is_a?(Numeric)
43
+ return left == right if left.is_a?(String) && right.is_a?(String)
44
+ return left == right if (left == true || left == false) && (right == true || right == false)
18
45
 
19
- path.split(".").reduce(obj) { |o, i| o&.[](i.to_sym) || o&.[](i) }
46
+ false
20
47
  end
48
+ private_class_method :strict_equal?
21
49
 
22
50
  # Check if a condition is matched against context
23
51
  # @param condition [Hash] Condition to evaluate
@@ -30,18 +58,19 @@ module Featurevisor
30
58
  value = condition["value"] || condition[:value]
31
59
  regex_flags = condition["regexFlags"] || condition[:regexFlags]
32
60
 
33
- context_value_from_path = get_value_from_context(context, attribute)
61
+ raw_context_value = get_value_with_presence(context, attribute)
62
+ context_value_from_path = raw_context_value.equal?(MISSING) ? nil : raw_context_value
63
+ attribute_exists = !raw_context_value.equal?(MISSING)
34
64
 
35
65
  case operator
36
66
  when "equals"
37
- context_value_from_path == value
67
+ attribute_exists && strict_equal?(context_value_from_path, value)
38
68
  when "notEquals"
39
- context_value_from_path != value
69
+ !attribute_exists || !strict_equal?(context_value_from_path, value)
40
70
  when "before", "after"
41
- # date comparisons
42
- value_in_context = context_value_from_path
43
- date_in_context = value_in_context.is_a?(Date) ? value_in_context : Date.parse(value_in_context.to_s)
44
- date_in_condition = value.is_a?(Date) ? value : Date.parse(value.to_s)
71
+ date_in_context = portable_date(context_value_from_path)
72
+ date_in_condition = portable_date(value)
73
+ return false unless date_in_context && date_in_condition
45
74
 
46
75
  if operator == "before"
47
76
  date_in_context < date_in_condition
@@ -50,21 +79,13 @@ module Featurevisor
50
79
  end
51
80
  when "in", "notIn"
52
81
  # in / notIn (where condition value is an array)
53
- if value.is_a?(Array) && (context_value_from_path.is_a?(String) || context_value_from_path.is_a?(Numeric) || context_value_from_path.nil?)
54
- # Check if the attribute key actually exists in the context
55
- key_exists = context.key?(attribute.to_sym) || context.key?(attribute.to_s)
56
-
57
- # If key doesn't exist, notIn should fail (return false), in should also fail
58
- if !key_exists
59
- return false
60
- end
61
-
62
- value_in_context = context_value_from_path.to_s
63
-
82
+ if attribute_exists && value.is_a?(Array) &&
83
+ (context_value_from_path.is_a?(String) || context_value_from_path.is_a?(Numeric) || context_value_from_path.nil?)
84
+ matched = value.any? { |candidate| strict_equal?(candidate, context_value_from_path) }
64
85
  if operator == "in"
65
- value.include?(value_in_context)
86
+ matched
66
87
  else # notIn
67
- !value.include?(value_in_context)
88
+ !matched
68
89
  end
69
90
  else
70
91
  false
@@ -124,18 +145,18 @@ module Featurevisor
124
145
  false
125
146
  end
126
147
  when "exists"
127
- context_value_from_path != nil
148
+ attribute_exists
128
149
  when "notExists"
129
- context_value_from_path.nil?
150
+ !attribute_exists
130
151
  when "includes", "notIncludes"
131
152
  # includes / notIncludes (where context value is an array)
132
- if context_value_from_path.is_a?(Array) && value.is_a?(String)
133
- value_in_context = context_value_from_path
134
-
153
+ if context_value_from_path.is_a?(Array) &&
154
+ (value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.nil?)
155
+ matched = context_value_from_path.any? { |candidate| strict_equal?(candidate, value) }
135
156
  if operator == "includes"
136
- value_in_context.include?(value)
157
+ matched
137
158
  else # notIncludes
138
- !value_in_context.include?(value)
159
+ !matched
139
160
  end
140
161
  else
141
162
  false
@@ -143,10 +164,18 @@ module Featurevisor
143
164
  else
144
165
  false
145
166
  end
146
- rescue => e
147
- # Log error but don't stop execution
148
- warn "Error in condition evaluation: #{e.message}"
149
- false
150
167
  end
168
+
169
+ def self.portable_date(value)
170
+ return value if value.is_a?(Time) || value.is_a?(DateTime)
171
+ return value.to_time if value.is_a?(Date)
172
+ return nil unless value.is_a?(String)
173
+ return nil unless value.match?(/T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+\-]\d{2}:\d{2})\z/)
174
+
175
+ Time.iso8601(value)
176
+ rescue ArgumentError
177
+ nil
178
+ end
179
+ private_class_method :portable_date
151
180
  end
152
181
  end
@@ -1,21 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Featurevisor
4
- # Log levels for the logger
4
+ # Diagnostic severity levels
5
5
  LOG_LEVELS = %w[fatal error warn info debug].freeze
6
6
  DEFAULT_LOG_LEVEL = "info".freeze
7
- LOGGER_PREFIX = "[Featurevisor]".freeze
7
+ DIAGNOSTIC_PREFIX = "[Featurevisor]".freeze
8
8
 
9
- # Logger class for handling different log levels
10
- class Logger
9
+ # Private evaluator adapter for structured diagnostics.
10
+ class DiagnosticReporter
11
11
  attr_reader :level, :handler
12
12
 
13
- # Initialize a new logger
14
- # @param options [Hash] Logger options
13
+ # Initialize a diagnostic reporter.
14
+ # @param options [Hash] Reporter options
15
15
  # @option options [String] :level Log level (default: "info")
16
- # @option options [Proc] :handler Custom log handler (default: default_log_handler)
16
+ # @option options [Proc] :handler Internal structured diagnostic sink
17
17
  def initialize(options = {})
18
18
  @level = options[:level] || DEFAULT_LOG_LEVEL
19
+ @filter = !options.key?(:handler)
19
20
  @handler = options[:handler] || method(:default_log_handler)
20
21
  end
21
22
 
@@ -25,12 +26,13 @@ module Featurevisor
25
26
  @level = level
26
27
  end
27
28
 
28
- # Log a message at a specific level
29
- # @param level [String] Log level
30
- # @param message [String] Log message
29
+ # Forward an evaluator diagnostic. Filtering is performed centrally by
30
+ # Instance so module subscriptions and error events remain independent.
31
+ # @param level [String] Diagnostic level
32
+ # @param message [String] Diagnostic message
31
33
  # @param details [Hash, nil] Additional details
32
34
  def log(level, message, details = nil)
33
- return unless should_handle?(level)
35
+ return if @filter && !should_handle?(level)
34
36
 
35
37
  @handler.call(level, message, details)
36
38
  end
@@ -72,16 +74,8 @@ module Featurevisor
72
74
 
73
75
  private
74
76
 
75
- # Check if the current level should handle the given log level
76
- # @param log_level [String] Log level to check
77
- # @return [Boolean] True if should handle
78
- def should_handle?(log_level)
79
- current_index = LOG_LEVELS.index(@level)
80
- target_index = LOG_LEVELS.index(log_level)
81
-
82
- return false if current_index.nil? || target_index.nil?
83
-
84
- current_index >= target_index
77
+ def should_handle?(level)
78
+ LOG_LEVELS.index(level).to_i <= LOG_LEVELS.index(@level).to_i
85
79
  end
86
80
 
87
81
  # Default log handler that outputs to console
@@ -99,45 +93,18 @@ module Featurevisor
99
93
  case method_name
100
94
  when "puts"
101
95
  if details && !details.empty?
102
- Kernel.puts("#{LOGGER_PREFIX} #{message} #{details.inspect}")
96
+ Kernel.puts("#{DIAGNOSTIC_PREFIX} #{message} #{details.inspect}")
103
97
  else
104
- Kernel.puts("#{LOGGER_PREFIX} #{message}")
98
+ Kernel.puts("#{DIAGNOSTIC_PREFIX} #{message}")
105
99
  end
106
100
  when "warn"
107
101
  if details && !details.empty?
108
- Kernel.warn("#{LOGGER_PREFIX} #{message} #{details.inspect}")
102
+ Kernel.warn("#{DIAGNOSTIC_PREFIX} #{message} #{details.inspect}")
109
103
  else
110
- Kernel.warn("#{LOGGER_PREFIX} #{message}")
104
+ Kernel.warn("#{DIAGNOSTIC_PREFIX} #{message}")
111
105
  end
112
106
  end
113
107
  end
114
108
  end
115
109
 
116
- # Default log handler function
117
- # @param level [String] Log level
118
- # @param message [String] Log message
119
- # @param details [Hash, nil] Additional details
120
- def self.default_log_handler(level, message, details = nil)
121
- method_name = case level
122
- when "info" then "puts"
123
- when "warn" then "warn"
124
- when "error", "fatal" then "warn"
125
- else "puts"
126
- end
127
-
128
- case method_name
129
- when "puts"
130
- if details && !details.empty?
131
- Kernel.puts("#{LOGGER_PREFIX} #{message} #{details.inspect}")
132
- else
133
- Kernel.puts("#{LOGGER_PREFIX} #{message}")
134
- end
135
- when "warn"
136
- if details && !details.empty?
137
- Kernel.warn("#{LOGGER_PREFIX} #{message} #{details.inspect}")
138
- else
139
- Kernel.warn("#{LOGGER_PREFIX} #{message}")
140
- end
141
- end
142
- end
143
110
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Featurevisor
4
4
  # Event names for the emitter
5
- EVENT_NAMES = %w[datafile_set context_set sticky_set error].freeze
5
+ EVENT_NAMES = %w[datafile_set context_set sticky_features_set sticky_variables_set error].freeze
6
6
 
7
7
  # Event emitter class for handling event subscriptions and triggers
8
8
  class Emitter