featurevisor 3.1.0 → 4.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.
@@ -10,9 +10,13 @@ module Featurevisor
10
10
  @sticky_variables = options[:sticky_variables] || {}
11
11
  @emitter = Featurevisor::Emitter.new
12
12
  @parent_unsubscribers = []
13
+ @closed = false
14
+ @evaluation_generation = 0
13
15
  end
14
16
 
15
17
  def on(event_name, callback = nil, &block)
18
+ return -> {} if @closed
19
+
16
20
  callback = block if block_given?
17
21
  if %w[context_set sticky_features_set sticky_variables_set].include?(event_name)
18
22
  return @emitter.on(event_name, callback)
@@ -33,6 +37,9 @@ module Featurevisor
33
37
  end
34
38
 
35
39
  def close
40
+ return if @closed
41
+
42
+ @closed = true
36
43
  @parent_unsubscribers.dup.each(&:call)
37
44
  @parent_unsubscribers.clear
38
45
  @emitter.clear_all
@@ -40,6 +47,7 @@ module Featurevisor
40
47
 
41
48
  def set_context(context, replace = false)
42
49
  @context = replace ? context : { **@context, **context }
50
+ @evaluation_generation += 1
43
51
  @emitter.trigger("context_set", context: @context, replaced: replace)
44
52
  end
45
53
 
@@ -50,6 +58,7 @@ module Featurevisor
50
58
  def set_sticky_features(sticky, replace = false)
51
59
  previous = @sticky_features
52
60
  @sticky_features = replace ? sticky : { **@sticky_features, **sticky }
61
+ @evaluation_generation += 1
53
62
  params = Featurevisor::Events.get_params_for_sticky_features_set_event(previous, @sticky_features, replace)
54
63
  @emitter.trigger("sticky_features_set", params)
55
64
  end
@@ -57,6 +66,7 @@ module Featurevisor
57
66
  def set_sticky_variables(sticky, replace = false)
58
67
  previous = @sticky_variables
59
68
  @sticky_variables = replace ? sticky : { **@sticky_variables, **sticky }
69
+ @evaluation_generation += 1
60
70
  @emitter.trigger(
61
71
  "sticky_variables_set",
62
72
  Featurevisor::Events.get_params_for_sticky_variables_set_event(previous, @sticky_variables, replace)
@@ -87,7 +97,7 @@ module Featurevisor
87
97
  delegate_variable(:evaluate_variable, feature_or_variable_key, variable_key_or_context, context_or_options, options)
88
98
  end
89
99
 
90
- %i[boolean string integer double array object json].each do |type|
100
+ %i[boolean string integer number array object json].each do |type|
91
101
  define_method("get_variable_#{type}") do |feature_or_variable_key, variable_key_or_context = nil, context_or_options = {}, options = {}|
92
102
  delegate_variable("get_variable_#{type}".to_sym, feature_or_variable_key, variable_key_or_context, context_or_options, options)
93
103
  end
@@ -111,7 +121,10 @@ module Featurevisor
111
121
  {
112
122
  **options,
113
123
  __featurevisor_child_sticky_features: @sticky_features,
114
- __featurevisor_child_sticky_variables: @sticky_variables
124
+ __featurevisor_child_sticky_variables: @sticky_variables,
125
+ __featurevisor_sticky_features_source: -> { @sticky_features },
126
+ __featurevisor_sticky_variables_source: -> { @sticky_variables },
127
+ __featurevisor_child_generation: -> { @evaluation_generation }
115
128
  }
116
129
  end
117
130
 
@@ -19,7 +19,7 @@ module Featurevisor
19
19
  end
20
20
 
21
21
  match = version.match(SEMVER_REGEX)
22
- unless match
22
+ unless match && version.ascii_only? && match[0] == version
23
23
  raise ArgumentError, "Invalid argument not valid semver ('#{version}' received)"
24
24
  end
25
25
 
@@ -34,50 +34,29 @@ module Featurevisor
34
34
  s == "*" || s.downcase == "x"
35
35
  end
36
36
 
37
- # Forces types to be the same for comparison
38
- # @param a [String, Integer] First value
39
- # @param b [String, Integer] Second value
40
- # @return [Array] Array with both values converted to same type
41
- def self.force_type(a, b)
42
- if a.is_a?(Integer) != b.is_a?(Integer)
43
- [a.to_s, b.to_s]
44
- else
45
- [a, b]
46
- end
47
- end
48
-
49
- # Tries to parse a string as an integer
50
- # @param v [String] String to parse
51
- # @return [Integer, String] Parsed integer or original string
52
- def self.try_parse(v)
53
- Integer(v, 10)
54
- rescue ArgumentError
55
- v
56
- end
57
-
58
37
  # Compares two strings for version comparison
59
38
  # @param a [String] First string
60
39
  # @param b [String] Second string
61
40
  # @return [Integer] -1 if a < b, 0 if equal, 1 if a > b
62
- def self.compare_strings(a, b)
63
- return 0 if wildcard?(a) || wildcard?(b)
64
-
65
- ap, bp = force_type(try_parse(a), try_parse(b))
66
-
67
- if ap > bp
68
- 1
69
- elsif ap < bp
70
- -1
71
- else
72
- 0
41
+ def self.compare_strings(a, b, prerelease = false)
42
+ return 0 if !prerelease && (wildcard?(a) || wildcard?(b))
43
+ an = /\A[0-9]+\z/.match?(a)
44
+ bn = /\A[0-9]+\z/.match?(b)
45
+ if an && bn
46
+ a = a.sub(/\A0+/, "")
47
+ b = b.sub(/\A0+/, "")
48
+ return a.length <=> b.length if a.length != b.length
49
+ elsif prerelease && an != bn
50
+ return an ? -1 : 1
73
51
  end
52
+ a <=> b
74
53
  end
75
54
 
76
55
  # Compares version segments
77
56
  # @param a [Array<String>, MatchData] First version segments
78
57
  # @param b [Array<String>, MatchData] Second version segments
79
58
  # @return [Integer] -1 if a < b, 0 if equal, 1 if a > b
80
- def self.compare_segments(a, b)
59
+ def self.compare_segments(a, b, prerelease = false)
81
60
  # Convert to arrays if needed
82
61
  a_array = a.is_a?(MatchData) ? a.to_a[1..-1] : a.to_a
83
62
  b_array = b.is_a?(MatchData) ? b.to_a[1..-1] : b.to_a
@@ -85,10 +64,13 @@ module Featurevisor
85
64
  max_length = [a_array.length, b_array.length].max
86
65
 
87
66
  (0...max_length).each do |i|
67
+ if prerelease && (i == a_array.length || i == b_array.length)
68
+ return a_array.length <=> b_array.length
69
+ end
88
70
  a_val = a_array[i] || "0"
89
71
  b_val = b_array[i] || "0"
90
72
 
91
- result = compare_strings(a_val, b_val)
73
+ result = compare_strings(a_val, b_val, prerelease)
92
74
  return result unless result == 0
93
75
  end
94
76
 
@@ -116,7 +98,7 @@ module Featurevisor
116
98
 
117
99
  # Validate pre-release
118
100
  if p1 && p2
119
- compare_segments(p1.split("."), p2.split("."))
101
+ compare_segments(p1.split("."), p2.split("."), true)
120
102
  elsif p1 || p2
121
103
  p1 ? -1 : 1
122
104
  else
@@ -23,6 +23,16 @@ module Featurevisor
23
23
  def self.get_value_with_presence(obj, path)
24
24
  return MISSING unless obj.is_a?(Hash) && path.is_a?(String)
25
25
 
26
+ # Most attributes are direct keys. Avoid allocating a path array for
27
+ # every condition while retaining the existing nested path behaviour.
28
+ unless path.empty? || path.include?(".")
29
+ symbol = path.to_sym
30
+ return obj[symbol] if obj.key?(symbol)
31
+ return obj[path] if obj.key?(path)
32
+
33
+ return MISSING
34
+ end
35
+
26
36
  path.split(".").reduce(obj) do |current, key|
27
37
  break MISSING unless current.is_a?(Hash)
28
38
 
@@ -39,7 +49,7 @@ module Featurevisor
39
49
 
40
50
  def self.strict_equal?(left, right)
41
51
  return true if left.nil? && right.nil?
42
- return left.to_f == right.to_f if left.is_a?(Numeric) && right.is_a?(Numeric)
52
+ return left == right if left.is_a?(Numeric) && right.is_a?(Numeric)
43
53
  return left == right if left.is_a?(String) && right.is_a?(String)
44
54
  return left == right if (left == true || left == false) && (right == true || right == false)
45
55
 
@@ -47,6 +57,15 @@ module Featurevisor
47
57
  end
48
58
  private_class_method :strict_equal?
49
59
 
60
+ def self.missing_value
61
+ MISSING
62
+ end
63
+
64
+ def self.value_is_matched(raw_value, condition, get_regex)
65
+ context = raw_value.equal?(MISSING) ? {} : { value: raw_value }
66
+ condition_is_matched(condition.merge(attribute: "value"), context, get_regex)
67
+ end
68
+
50
69
  # Check if a condition is matched against context
51
70
  # @param condition [Hash] Condition to evaluate
52
71
  # @param context [Hash] Context to evaluate against
@@ -55,7 +74,11 @@ module Featurevisor
55
74
  def self.condition_is_matched(condition, context, get_regex)
56
75
  attribute = condition["attribute"] || condition[:attribute]
57
76
  operator = condition["operator"] || condition[:operator]
58
- value = condition["value"] || condition[:value]
77
+ value = if condition.key?(:value)
78
+ condition[:value]
79
+ elsif condition.key?("value")
80
+ condition["value"]
81
+ end
59
82
  regex_flags = condition["regexFlags"] || condition[:regexFlags]
60
83
 
61
84
  raw_context_value = get_value_with_presence(context, attribute)
@@ -63,6 +86,18 @@ module Featurevisor
63
86
  attribute_exists = !raw_context_value.equal?(MISSING)
64
87
 
65
88
  case operator
89
+ when "empty", "notEmpty"
90
+ return false unless attribute_exists && !context_value_from_path.nil?
91
+
92
+ size = if context_value_from_path.is_a?(String) || context_value_from_path.is_a?(Array) ||
93
+ context_value_from_path.is_a?(Hash)
94
+ context_value_from_path.size
95
+ end
96
+ !size.nil? && (operator == "empty" ? size.zero? : size.positive?)
97
+ when "isNull"
98
+ attribute_exists && context_value_from_path.nil?
99
+ when "notNull"
100
+ attribute_exists && !context_value_from_path.nil?
66
101
  when "equals"
67
102
  attribute_exists && strict_equal?(context_value_from_path, value)
68
103
  when "notEquals"
@@ -148,6 +183,12 @@ module Featurevisor
148
183
  attribute_exists
149
184
  when "notExists"
150
185
  !attribute_exists
186
+ when "includesAny", "notIncludesAny"
187
+ return false unless context_value_from_path.is_a?(Array) && value.is_a?(Array)
188
+ return false unless value.all? { |candidate| candidate.nil? || candidate.is_a?(String) || candidate.is_a?(Integer) || candidate.is_a?(Float) || candidate == true || candidate == false }
189
+
190
+ matched = value.any? { |candidate| context_value_from_path.any? { |actual| strict_equal?(actual, candidate) } }
191
+ operator == "includesAny" ? matched : !matched
151
192
  when "includes", "notIncludes"
152
193
  # includes / notIncludes (where context value is an array)
153
194
  if context_value_from_path.is_a?(Array) &&
@@ -167,15 +208,56 @@ module Featurevisor
167
208
  end
168
209
 
169
210
  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
211
+ if value.is_a?(String)
212
+ match = /\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,9})?(?:Z|([+\-])([0-9]{2}):([0-9]{2}))\z/.match(value)
213
+ return nil unless match
214
+ return nil if value.start_with?("0000-")
215
+ if match[2] && (match[2].to_i > 14 || match[3].to_i > 59 || (match[2] == "14" && match[3] != "00"))
216
+ return nil
217
+ end
218
+ parsed = Time.iso8601(value)
219
+ # Time.iso8601 must not normalize an invalid calendar date or clock time.
220
+ return nil unless parsed.getlocal(match[1] ? "#{match[1]}#{match[2]}:#{match[3]}" : "+00:00").strftime("%Y-%m-%dT%H:%M:%S") == value[0, 19]
221
+ elsif value.is_a?(Time) || value.is_a?(DateTime)
222
+ return nil unless (1..9999).cover?(value.year)
223
+ offset_seconds = value.is_a?(DateTime) ? value.offset * 86400 : value.utc_offset
224
+ return nil if offset_seconds.abs > 14 * 3600
225
+ parsed = value.to_time
226
+ else
227
+ return nil
228
+ end
229
+ milliseconds = (parsed.to_r * 1000).floor
230
+ result = Time.at(Rational(milliseconds, 1000)).utc
231
+ return nil if result.year < 1 || result.year > 9999
232
+ result
233
+ rescue ArgumentError, RangeError
177
234
  nil
178
235
  end
179
236
  private_class_method :portable_date
237
+
238
+ def self.normalize_portable_regex(pattern)
239
+ inside = {
240
+ 'd' => '0-9', 'D' => '\\u{0}-\\u{2F}\\u{3A}-\\u{10FFFF}',
241
+ 'w' => 'A-Za-z0-9_', 'W' => '\\u{0}-\\u{2F}\\u{3A}-\\u{40}\\u{5B}-\\u{5E}\\u{60}\\u{7B}-\\u{10FFFF}'
242
+ }
243
+ outside = { 'd' => '[0-9]', 'D' => '[^0-9]', 'w' => '(?-i:[A-Za-z0-9_])', 'W' => '(?-i:[^A-Za-z0-9_])' }
244
+ output = +''
245
+ in_class = false
246
+ index = 0
247
+ while index < pattern.length
248
+ char = pattern[index]
249
+ if char == '\\' && index + 1 < pattern.length
250
+ code = pattern[index + 1]
251
+ output << (in_class ? inside : outside).fetch(code, pattern[index, 2])
252
+ index += 2
253
+ next
254
+ end
255
+ in_class = true if char == '[' && !in_class
256
+ in_class = false if char == ']' && in_class
257
+ output << char
258
+ index += 1
259
+ end
260
+ output
261
+ end
180
262
  end
181
263
  end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Featurevisor
6
+ module Dependencies
7
+ def self.extract_entity_dependencies(entry)
8
+ sources = {}
9
+ add = lambda do |kind, key, dependent|
10
+ source = [kind, key.to_sym]
11
+ sources[source] = true
12
+ end
13
+ conditions = nil
14
+ conditions = lambda do |value, dependent|
15
+ case value
16
+ when String
17
+ next if value == "*"
18
+ if value.lstrip.match?(/\A[\{\[\"]/)
19
+ begin
20
+ conditions.call(JSON.parse(value, symbolize_names: true), dependent)
21
+ rescue JSON::ParserError
22
+ nil
23
+ end
24
+ else
25
+ add.call(:segment, value, dependent)
26
+ end
27
+ when Array
28
+ value.each { |child| conditions.call(child, dependent) }
29
+ when Hash
30
+ if value.key?(:variable)
31
+ add.call(value[:feature] ? :feature : :variable, value[:feature] || value[:variable], dependent)
32
+ elsif value.key?(:feature)
33
+ add.call(:feature, value[:feature], dependent)
34
+ elsif !value.key?(:attribute)
35
+ %i[and or not].each { |group| conditions.call(value[group], dependent) if value.key?(group) }
36
+ end
37
+ end
38
+ end
39
+ visit = nil
40
+ visit = lambda do |entry, dependent|
41
+ conditions.call(entry[:conditions], dependent) if entry.key?(:conditions)
42
+ %i[force traffic variations overrides overrideProgram].each do |name|
43
+ (entry[name] || []).each { |child| visit.call(child, dependent) }
44
+ end
45
+ (entry[:variableOverrides] || {}).each_value do |overrides|
46
+ overrides.each { |child| visit.call(child, dependent) }
47
+ end
48
+ end
49
+ visit.call(entry, nil)
50
+ compose = entry[:compose]
51
+ if compose.is_a?(String)
52
+ add.call(:variable, compose, nil)
53
+ elsif compose.is_a?(Array)
54
+ compose.each do |reference|
55
+ add.call(reference[:feature] ? :feature : :variable, reference[:feature] || reference[:variable], nil)
56
+ end
57
+ end
58
+ sources.keys
59
+ end
60
+
61
+ COLLECTIONS = { segments: :segment, features: :feature, variables: :variable }.freeze
62
+
63
+ # Edge identities do not retain old providers, definitions or caller values.
64
+ class Index
65
+ EMPTY = [].freeze
66
+
67
+ def initialize
68
+ @reverse = {}
69
+ @owners = {}
70
+ @order = {}
71
+ @next_order = 0
72
+ end
73
+
74
+ def update(datafile, incoming, previous = nil)
75
+ updates = []
76
+ COLLECTIONS.each do |name, kind|
77
+ definitions = datafile[name] || {}
78
+ (incoming[name] || {}).each_key do |key|
79
+ owner = [kind, key.to_sym]
80
+ entry = definitions[key.to_sym]
81
+ # Hashes do not identify edges. Reuse only an identical definition.
82
+ next if previous && @owners.key?(owner) && entry && entry == (previous[name] || {})[key.to_sym]
83
+ updates << [owner, entry && Dependencies.extract_entity_dependencies(entry)]
84
+ end
85
+ end
86
+ updates.each do |owner, sources|
87
+ (@owners.delete(owner) || []).each do |source|
88
+ dependents = @reverse[source]
89
+ dependents.delete(owner)
90
+ @reverse.delete(source) if dependents.empty?
91
+ end
92
+ unless sources
93
+ @order.delete(owner)
94
+ next
95
+ end
96
+ unless @order.key?(owner)
97
+ @order[owner] = @next_order
98
+ @next_order += 1
99
+ end
100
+ @owners[owner] = sources
101
+ sources.each { |source| (@reverse[source] ||= {})[owner] = true }
102
+ end
103
+ end
104
+
105
+ def reorder(datafile)
106
+ @order = {}
107
+ COLLECTIONS.each do |name, kind|
108
+ (datafile[name] || {}).each_key.with_index { |key, rank| @order[[kind, key.to_sym]] = rank }
109
+ end
110
+ @next_order = (@order.values.max || -1) + 1
111
+ end
112
+
113
+ def dependents(identity)
114
+ dependents = @reverse[identity]
115
+ dependents ? dependents.keys.sort_by { |owner| @order.fetch(owner) } : EMPTY
116
+ end
117
+ end
118
+
119
+ def self.create_index(datafile)
120
+ index = Index.new
121
+ index.update(datafile, datafile)
122
+ index
123
+ end
124
+
125
+ def self.add_changes(features, variables, changed_segments, index)
126
+ kinds = %i[feature variable segment]
127
+ affected = [features, variables, changed_segments].map { |keys| keys.to_h { |key| [key.to_sym, true] } }
128
+ queues = affected.map(&:keys)
129
+ cursors = [0, 0, 0]
130
+ loop do
131
+ advanced = false
132
+ kinds.each_with_index do |kind, source|
133
+ next if cursors[source] >= queues[source].length
134
+ advanced = true
135
+ key = queues[source][cursors[source]]
136
+ cursors[source] += 1
137
+ index.dependents([kind, key]).each do |target_kind, target_key|
138
+ target = kinds.index(target_kind)
139
+ next if affected[target].key?(target_key)
140
+ affected[target][target_key] = true
141
+ queues[target] << target_key
142
+ end
143
+ end
144
+ break unless advanced
145
+ end
146
+ features.replace(affected[0].keys)
147
+ variables.replace(affected[1].keys)
148
+ end
149
+ end
150
+ end
@@ -31,6 +31,7 @@ module Featurevisor
31
31
  is_active = false
32
32
  index = listeners.index(callback)
33
33
  listeners.delete_at(index) if index && index >= 0
34
+ callback = nil
34
35
  end
35
36
  end
36
37
 
@@ -54,6 +55,9 @@ module Featurevisor
54
55
 
55
56
  # Clear all event listeners
56
57
  def clear_all
58
+ # Cleanup functions may still reference the previous listener arrays.
59
+ # Empty those arrays too, rather than retaining unrelated callbacks.
60
+ @listeners.each_value(&:clear)
57
61
  @listeners = {}
58
62
  end
59
63
  end