growthbook 1.3.0 → 2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d672aca084d165dd7fa2a67772e6db2bb42f80876a55758fe9dc6ace227e2388
4
- data.tar.gz: 4fd7709789e804bdd1a952ccf580e8fd34f265389725ca5d3f93e5eb4e073b32
3
+ metadata.gz: a9b291e75da702bbfbb6e2c8b11ff3c58ae5a0cd3e698d653a1e2be9839c191c
4
+ data.tar.gz: 84ffb3e30db50ffaefebabd45763632ae991777c8971834e771e07228d0e2080
5
5
  SHA512:
6
- metadata.gz: 6b2a49f5ff71788343f3c8c20d8ea32197b94131e0a93ce5f6d76baa706cc1465b03c3eb1be4208aec5c71672d13442425d9623f56ba1162498613fa91c4f059
7
- data.tar.gz: e6dab10324ad1e4673db49d5db4b1e5b7b5f97e1153a72d1b19e8607e84c068f1298499d0fd6994a33c132d750a4013d021c197d6e16a8d720c61f9bb657be10
6
+ metadata.gz: 79643b7a144b358ffa0d9bb82eecc4c83720acd23737669d177e90ca52386aff8dba25186f1614eb217aeb3ae4895879937565bd98124e4345d9c24d9604d620
7
+ data.tar.gz: 7ecaf35f1b002cec94653c3e624e1173c105265ede39209c0a3ef69cccf1c698daa2c3bf046fe49a8b869aa06abaebf2217ca15926d20b90bb4dc449318a7bd4
@@ -1,24 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'set'
4
5
 
5
6
  module Growthbook
6
7
  # internal use only
7
8
  # Utils for condition evaluation
8
- class Conditions
9
+ class Conditions # rubocop:disable Metrics/ClassLength
10
+ # Keys a condition can have that aren't attributes, besides $not, which is
11
+ # also an operator on a value
12
+ CONDITION_OPERATORS = %w[$or $nor $and $savedGroup].freeze
13
+
14
+ # Keys that pass when what they hold fails
15
+ NEGATING_KEYS = %w[$not $nor].freeze
16
+
17
+ # Saved groups for one evaluation, and the group ids already being resolved, to stop cycles
18
+ class EvalContext
19
+ # @return [Hash] Saved groups payload, keyed by group id
20
+ attr_reader :saved_groups
21
+
22
+ # @return [Set<String>] Ids of the saved groups being resolved
23
+ attr_reader :visited
24
+
25
+ def initialize(saved_groups, visited = Set.new)
26
+ @saved_groups = saved_groups || {}
27
+ @visited = visited
28
+ end
29
+
30
+ # A copy that also holds id, so sibling groups don't see each other's ids
31
+ def visiting(id)
32
+ EvalContext.new(@saved_groups, @visited.dup.add(id))
33
+ end
34
+ end
35
+
9
36
  # Evaluate a targeting conditions hash against an attributes hash
10
37
  # Both attributes and conditions only have string keys (no symbols)
11
- def self.eval_condition(attributes, condition)
12
- return eval_or(attributes, condition['$or']) if condition.key?('$or')
13
- return !eval_or(attributes, condition['$nor']) if condition.key?('$nor')
14
- return eval_and(attributes, condition['$and']) if condition.key?('$and')
15
- return !eval_condition(attributes, condition['$not']) if condition.key?('$not')
38
+ def self.eval_condition(attributes, condition, saved_groups = {})
39
+ eval_condition_result(attributes, condition, EvalContext.new(saved_groups)) == true
40
+ end
16
41
 
17
- condition.each do |key, value|
18
- return false unless eval_condition_value(value, get_path(attributes, key))
19
- end
42
+ # Partially evaluate a condition when only some attributes are known, e.g. on
43
+ # a server. Attributes missing from `attributes` are unknown. Returns true or
44
+ # false once the result can't depend on them, otherwise the condition minus
45
+ # every branch that is already decided.
46
+ def self.prune_condition(attributes, condition, saved_groups = {})
47
+ prune_with_context(attributes, condition, EvalContext.new(saved_groups))
48
+ end
20
49
 
21
- true
50
+ # Prune the saved group a reference points to. Returns its result once decided
51
+ # (nil for a group type this SDK doesn't know), otherwise its condition minus
52
+ # every branch that is already decided. A malformed condition group stays as written.
53
+ def self.prune_saved_group(attributes, reference, saved_groups)
54
+ eval_saved_group(attributes, reference, EvalContext.new(saved_groups), prune: true, keep_malformed: true)
22
55
  end
23
56
 
24
57
  # Helper function to ensure conditions only have string keys (no symbols)
@@ -27,25 +60,172 @@ module Growthbook
27
60
  when Array
28
61
  return condition.map { |v| parse_condition(v) }
29
62
  when Hash
30
- return condition.to_h { |k, v| [k.to_s, parse_condition(v)] }
63
+ # Not to_h with a block, which Ruby 2.5 ignores
64
+ return condition.each_with_object({}) { |(k, v), h| h[k.to_s] = parse_condition(v) }
31
65
  end
32
66
  condition
33
67
  end
34
68
 
35
- def self.eval_or(attributes, conditions)
69
+ # Evaluates a condition to true or false, or nil when the result depends on an
70
+ # operator or saved group type this SDK doesn't support
71
+ def self.eval_condition_result(attributes, condition, ctx)
72
+ # @type var result: bool?
73
+ result = true
74
+ condition.each do |key, value|
75
+ r = case key
76
+ when '$or' then eval_or(attributes, value, ctx)
77
+ when '$nor' then negate(eval_or(attributes, value, ctx))
78
+ when '$and' then eval_and(attributes, value, ctx)
79
+ when '$not' then negate(eval_condition_result(attributes, value, ctx))
80
+ when '$savedGroup' then eval_saved_group(attributes, value, ctx)
81
+ else eval_condition_value(value, get_path(attributes, key), ctx)
82
+ end
83
+ # One failing entry decides it, wherever an unsupported one is
84
+ return false if r == false
85
+
86
+ result = nil if r.nil?
87
+ end
88
+ result
89
+ end
90
+
91
+ # $not of an undecided result stays undecided
92
+ def self.negate(result)
93
+ result.nil? ? nil : !result
94
+ end
95
+
96
+ def self.eval_or(attributes, conditions, ctx)
36
97
  return true if conditions.length <= 0
37
98
 
99
+ # @type var result: bool?
100
+ result = false
38
101
  conditions.each do |condition|
39
- return true if eval_condition(attributes, condition)
102
+ r = eval_condition_result(attributes, condition, ctx)
103
+ return true if r == true
104
+
105
+ result = nil if r.nil?
40
106
  end
41
- false
107
+ result
42
108
  end
43
109
 
44
- def self.eval_and(attributes, conditions)
110
+ def self.eval_and(attributes, conditions, ctx)
111
+ # @type var result: bool?
112
+ result = true
45
113
  conditions.each do |condition|
46
- return false unless eval_condition(attributes, condition)
114
+ r = eval_condition_result(attributes, condition, ctx)
115
+ return false if r == false
116
+
117
+ result = nil if r.nil?
47
118
  end
48
- true
119
+ result
120
+ end
121
+
122
+ # Resolves a `$savedGroup` reference. Anything unrecognized matches nobody.
123
+ # With prune, an unknown attribute leaves a list group undecided (nil), and a
124
+ # condition group is pruned rather than evaluated. With keep_malformed, a
125
+ # condition group that raises while pruning is returned as written.
126
+ def self.eval_saved_group(attributes, reference, ctx, prune: false, keep_malformed: false)
127
+ return false unless reference.is_a?(Hash)
128
+
129
+ id = reference['id']
130
+ attribute_key = reference['attributeKey']
131
+ return false if !id.is_a?(String) || ctx.visited.include?(id)
132
+ # Only an absent override falls back to the entry's attribute. Falling back
133
+ # for any other non-string, nil included, would check a different population.
134
+ return false if reference.key?('attributeKey') && !attribute_key.is_a?(String)
135
+
136
+ entry = ctx.saved_groups[id]
137
+ # Absent, or a v1 bare array
138
+ return false unless entry.is_a?(Hash)
139
+
140
+ case entry['type']
141
+ when 'list'
142
+ # The override wins over the entry's own attribute
143
+ key = attribute_key || entry['attributeKey']
144
+ return false unless key.is_a?(String)
145
+ return false unless entry['values'].is_a?(Array)
146
+ return nil if prune && !known?(attributes, key)
147
+
148
+ in?(get_path(attributes, key), entry['values'])
149
+ when 'condition'
150
+ # A condition group has no single attribute, so any override is ignored
151
+ return false unless entry['condition'].is_a?(Hash)
152
+
153
+ group_ctx = ctx.visiting(id)
154
+ return eval_condition_result(attributes, entry['condition'], group_ctx) unless prune
155
+
156
+ # Inside a condition being pruned, the error reaches that condition's prune_with_context
157
+ keep_malformed ? prune_with_context(attributes, entry['condition'], group_ctx) : prune_entries(attributes, entry['condition'], group_ctx)
158
+ end
159
+ # Any other type was added after this SDK was built. The client SDK may support it.
160
+ end
161
+
162
+ def self.prune_with_context(attributes, condition, ctx)
163
+ prune_entries(attributes, condition, ctx)
164
+ rescue StandardError
165
+ # One that raises, such as a malformed one, stays as written. Evaluating it
166
+ # fails as a whole, so no part of it can be decided.
167
+ condition
168
+ end
169
+
170
+ def self.prune_entries(attributes, condition, ctx)
171
+ out = {}
172
+ condition.each do |key, value|
173
+ r = case key
174
+ when '$or', '$nor', '$and' then prune_list(attributes, value, ctx, or_list: key != '$and')
175
+ when '$not' then prune_entries(attributes, value, ctx)
176
+ when '$savedGroup'
177
+ # Keep an undecided reference as is rather than inlining the group
178
+ decided = eval_saved_group(attributes, value, ctx, prune: true)
179
+ decided?(decided) ? decided : nil
180
+ else
181
+ # nil when the attribute is unknown, or the value uses an operator
182
+ # this SDK doesn't support, which the client's SDK may
183
+ known?(attributes, key) ? eval_condition_value(value, get_path(attributes, key), ctx) : nil
184
+ end
185
+ # $not and $nor pass when what they hold fails
186
+ r = !r if NEGATING_KEYS.include?(key) && decided?(r)
187
+
188
+ # One failing entry fails the whole condition, and a passing one is dropped
189
+ return false if r == false
190
+
191
+ # nil is an undecided leaf, which stays as written
192
+ if r.nil?
193
+ out[key] = value
194
+ elsif r != true
195
+ out[key] = r
196
+ end
197
+ end
198
+ out.empty? ? true : out
199
+ end
200
+
201
+ # Prune an $or (or an $and) list. Returns the conditions still undecided, or
202
+ # the list's result once there are none.
203
+ def self.prune_list(attributes, conditions, ctx, or_list:)
204
+ raise TypeError, 'expected an array of conditions' unless conditions.is_a?(Array)
205
+
206
+ out = []
207
+ conditions.each do |condition|
208
+ r = prune_entries(attributes, condition, ctx)
209
+ # One passing condition decides an $or, and one failing condition an $and
210
+ return true if or_list && r == true
211
+ return false if !or_list && r == false
212
+
213
+ out << r unless decided?(r)
214
+ end
215
+ return out unless out.empty?
216
+
217
+ # Every condition failed an $or, or passed an $and. Like eval_or, an empty $or passes.
218
+ or_list ? conditions.empty? : true
219
+ end
220
+
221
+ # True or false, rather than nil or a pruned condition
222
+ def self.decided?(result)
223
+ result.is_a?(TrueClass) || result.is_a?(FalseClass)
224
+ end
225
+
226
+ # A nested path is unknown, since the server may only have part of the object
227
+ def self.known?(attributes, path)
228
+ !path.include?('.') && attributes.is_a?(Hash) && attributes.key?(path)
49
229
  end
50
230
 
51
231
  def self.operator_object?(obj)
@@ -73,102 +253,177 @@ module Growthbook
73
253
  current = attributes
74
254
 
75
255
  parts.each do |value|
76
- return nil unless current.is_a?(Hash) && current&.key?(value)
77
-
78
- current = current[value]
256
+ if current.is_a?(Hash) && current.key?(value)
257
+ current = current[value]
258
+ elsif current.is_a?(Array) && value.match?(/\A(0|[1-9][0-9]*)\z/) && value.to_i < current.length
259
+ # Like JS, a segment can index into an array ("tags.0")
260
+ current = current[value.to_i]
261
+ elsif current.is_a?(Array) && value == 'length'
262
+ current = current.length
263
+ else
264
+ return nil
265
+ end
79
266
  end
80
267
 
81
268
  current
82
269
  end
83
270
 
84
- def self.eval_condition_value(condition_value, attribute_value)
271
+ def self.eval_condition_value(condition_value, attribute_value, ctx, insensitive: false)
85
272
  if condition_value.is_a?(Hash) && operator_object?(condition_value)
273
+ # @type var result: bool?
274
+ result = true
86
275
  condition_value.each do |key, value|
87
- return false unless eval_operator_condition(key, attribute_value, value)
276
+ r = eval_operator_condition(key, attribute_value, value, ctx)
277
+ return false if r == false
278
+
279
+ result = nil if r.nil?
88
280
  end
89
- return true
281
+ return result
282
+ end
283
+ return condition_value.casecmp?(attribute_value) if insensitive && condition_value.is_a?(String) && attribute_value.is_a?(String)
284
+
285
+ # null matches only null, like JS ===; js_json would also write NaN and infinity as "null"
286
+ return attribute_value.nil? if condition_value.nil?
287
+
288
+ # Numbers compare by value, like JS ===, so 1.0 equals 1
289
+ return number?(attribute_value) && condition_value == attribute_value if number?(condition_value)
290
+
291
+ js_json(condition_value) == js_json(attribute_value)
292
+ end
293
+
294
+ # Serializes like JavaScript's JSON.stringify, which writes 1.0 as "1" and -0.0 as "0"
295
+ def self.js_json(value)
296
+ case value
297
+ when Hash then "{#{value.map { |k, v| "#{k.to_s.to_json}:#{js_json(v)}" }.join(',')}}"
298
+ when Array then "[#{value.map { |v| js_json(v) }.join(',')}]"
299
+ when Integer, Float then value.to_f.finite? ? Util.js_number_to_s(value) : 'null'
300
+ else value.to_json
90
301
  end
91
- condition_value.to_json == attribute_value.to_json
92
302
  end
93
303
 
94
- def self.elem_match(condition, attribute_value)
304
+ def self.elem_match(condition, attribute_value, ctx)
95
305
  return false unless attribute_value.is_a? Array
96
306
 
307
+ # Operators test each element itself. A condition, including one that only
308
+ # uses $or, $and, $nor, or $savedGroup, tests each element's fields.
309
+ on_value = operator_object?(condition) && condition.keys.none? { |k| CONDITION_OPERATORS.include?(k) }
310
+ # @type var result: bool?
311
+ result = false
97
312
  attribute_value.each do |item|
98
- if operator_object?(condition)
99
- return true if eval_condition_value(condition, item)
100
- elsif eval_condition(item, condition)
101
- return true
102
- end
313
+ next if item.nil?
314
+
315
+ r = on_value ? eval_condition_value(condition, item, ctx) : eval_condition_result(item, condition, ctx)
316
+ return true if r == true
317
+
318
+ result = nil if r.nil?
103
319
  end
104
- false
320
+ result
105
321
  end
106
322
 
107
- def self.compare(val1, val2)
108
- if val1.is_a?(Numeric) || val2.is_a?(Numeric)
109
- val1 = val1.is_a?(Numeric) ? val1 : val1.to_f
110
- val2 = val2.is_a?(Numeric) ? val2 : val2.to_f
111
- end
323
+ # Only two numbers or two strings can be ordered; anything else is a type mismatch
324
+ def self.orderable?(val1, val2)
325
+ (number?(val1) && number?(val2)) || (val1.is_a?(String) && val2.is_a?(String))
326
+ end
112
327
 
113
- return 1 if val1 > val2
114
- return -1 if val1 < val2
328
+ def self.number?(value)
329
+ value.is_a?(Integer) || value.is_a?(Float)
330
+ end
331
+
332
+ # On a type mismatch $vne matches and every other version operator fails
333
+ def self.eval_version_operator(operator, attribute_value, condition_value)
334
+ return operator == '$vne' unless orderable?(attribute_value, condition_value)
115
335
 
116
- 0
336
+ a = padded_version_string(attribute_value)
337
+ b = padded_version_string(condition_value)
338
+ case operator
339
+ when '$veq' then a == b
340
+ when '$vne' then a != b
341
+ when '$vgt' then a > b
342
+ when '$vgte' then a >= b
343
+ when '$vlt' then a < b
344
+ when '$vlte' then a <= b
345
+ else false
346
+ end
117
347
  end
118
348
 
119
- def self.eval_operator_condition(operator, attribute_value, condition_value)
349
+ def self.eval_compare_operator(operator, attribute_value, condition_value)
120
350
  case operator
121
- when '$veq'
122
- padded_version_string(attribute_value) == padded_version_string(condition_value)
123
- when '$vne'
124
- padded_version_string(attribute_value) != padded_version_string(condition_value)
125
- when '$vgt'
126
- padded_version_string(attribute_value) > padded_version_string(condition_value)
127
- when '$vgte'
128
- padded_version_string(attribute_value) >= padded_version_string(condition_value)
129
- when '$vlt'
130
- padded_version_string(attribute_value) < padded_version_string(condition_value)
131
- when '$vlte'
132
- padded_version_string(attribute_value) <= padded_version_string(condition_value)
133
- when '$eq'
134
- begin
135
- compare(attribute_value, condition_value).zero?
136
- rescue StandardError
137
- false
138
- end
139
- when '$ne'
140
- begin
141
- compare(attribute_value, condition_value) != 0
142
- rescue StandardError
143
- false
144
- end
145
- when '$lt'
146
- begin
147
- compare(attribute_value, condition_value).negative?
148
- rescue StandardError
149
- false
150
- end
151
- when '$lte'
152
- begin
153
- compare(attribute_value, condition_value) <= 0
154
- rescue StandardError
155
- false
156
- end
157
- when '$gt'
158
- begin
159
- compare(attribute_value, condition_value).positive?
160
- rescue StandardError
161
- false
351
+ when '$eq' then strict_equal?(attribute_value, condition_value)
352
+ when '$ne' then !strict_equal?(attribute_value, condition_value)
353
+ else
354
+ return false unless orderable?(attribute_value, condition_value)
355
+
356
+ attribute_value, condition_value = utf16_units(attribute_value, condition_value)
357
+ case operator
358
+ when '$lt' then attribute_value < condition_value
359
+ when '$lte' then attribute_value <= condition_value
360
+ when '$gt' then attribute_value > condition_value
361
+ when '$gte' then attribute_value >= condition_value
362
+ else false
162
363
  end
163
- when '$gte'
164
- begin
165
- compare(attribute_value, condition_value) >= 0
364
+ end
365
+ end
366
+
367
+ # JS orders strings by UTF-16 code units, so a character above U+FFFF (a
368
+ # surrogate pair) sorts below U+E000..U+FFFF. Big-endian UTF-16 bytes
369
+ # compare in that order.
370
+ def self.utf16_units(val1, val2)
371
+ return [val1, val2] unless val1.is_a?(String) && val2.is_a?(String)
372
+ return [val1, val2] if val1.ascii_only? && val2.ascii_only?
373
+
374
+ [val1.encode('UTF-16BE').b, val2.encode('UTF-16BE').b]
375
+ end
376
+
377
+ # Like JS ===: values of different types are never equal (no "5" == 5 or
378
+ # 0 == nil), numbers compare by value (1 equals 1.0), and arrays and objects
379
+ # compare by reference, so two parsed from JSON are never equal
380
+ def self.strict_equal?(val1, val2)
381
+ return false if val1.is_a?(Array) || val1.is_a?(Hash) || val2.is_a?(Array) || val2.is_a?(Hash)
382
+
383
+ get_type(val1) == get_type(val2) && val1 == val2
384
+ end
385
+
386
+ # Gets the values of a saved group for $inGroup/$notInGroup: a bare array, or
387
+ # the values of a typed list entry. nil when it has no values to check.
388
+ def self.saved_group_values(saved_groups, id)
389
+ # An unknown id has always behaved like an empty list. A malformed entry
390
+ # has not, and must not: $notInGroup would pass everyone.
391
+ return [] unless saved_groups.key?(id)
392
+
393
+ entry = saved_groups[id]
394
+ return entry if entry.is_a?(Array)
395
+ # These operators take their attribute from the condition they sit on
396
+ return entry['values'].is_a?(Array) ? entry['values'] : nil if entry.is_a?(Hash) && entry['type'] == 'list'
397
+
398
+ # A condition group, a malformed entry, or an unknown type
399
+ nil
400
+ end
401
+
402
+ # A saved group type added after this SDK was built
403
+ def self.new_group_type?(entry)
404
+ entry.is_a?(Hash) && !%w[list condition].include?(entry['type'])
405
+ end
406
+
407
+ def self.eval_operator_condition(operator, attribute_value, condition_value, ctx)
408
+ case operator
409
+ when '$veq', '$vne', '$vgt', '$vgte', '$vlt', '$vlte'
410
+ eval_version_operator(operator, attribute_value, condition_value)
411
+ when '$eq', '$ne', '$lt', '$lte', '$gt', '$gte'
412
+ eval_compare_operator(operator, attribute_value, condition_value)
413
+ when '$regex'
414
+ return false unless attribute_value.is_a?(String)
415
+
416
+ silence_warnings do
417
+ re = Regexp.new(condition_value)
418
+ !!attribute_value.match(re)
166
419
  rescue StandardError
167
420
  false
168
421
  end
169
- when '$regex'
422
+ when '$regexi'
423
+ return false unless attribute_value.is_a?(String)
424
+
170
425
  silence_warnings do
171
- re = Regexp.new(condition_value)
426
+ re = Regexp.new(condition_value, Regexp::IGNORECASE)
172
427
  !!attribute_value.match(re)
173
428
  rescue StandardError
174
429
  false
@@ -181,23 +436,37 @@ module Growthbook
181
436
  return false unless condition_value.is_a?(Array)
182
437
 
183
438
  !in?(attribute_value, condition_value)
439
+ when '$ini'
440
+ return false unless condition_value.is_a?(Array)
441
+
442
+ in?(attribute_value, condition_value, insensitive: true)
443
+ when '$nini'
444
+ return false unless condition_value.is_a?(Array)
445
+
446
+ !in?(attribute_value, condition_value, insensitive: true)
447
+ when '$inGroup', '$notInGroup'
448
+ entry = condition_value.is_a?(String) ? ctx.saved_groups[condition_value] : nil
449
+ return nil if new_group_type?(entry)
450
+
451
+ # No values means matching nobody. An empty list would pass everyone.
452
+ values = condition_value.is_a?(String) ? saved_group_values(ctx.saved_groups, condition_value) : nil
453
+ return false if values.nil?
454
+
455
+ operator == '$inGroup' ? in?(attribute_value, values) : !in?(attribute_value, values)
184
456
  when '$elemMatch'
185
- elem_match(condition_value, attribute_value)
457
+ elem_match(condition_value, attribute_value, ctx)
186
458
  when '$size'
187
459
  return false unless attribute_value.is_a? Array
188
460
 
189
- eval_condition_value(condition_value, attribute_value.length)
461
+ eval_condition_value(condition_value, attribute_value.length, ctx)
190
462
  when '$all'
191
- return false unless attribute_value.is_a? Array
463
+ return false unless condition_value.is_a?(Array)
192
464
 
193
- condition_value.each do |condition|
194
- passed = false
195
- attribute_value.each do |attr|
196
- passed = true if eval_condition_value(condition, attr)
197
- end
198
- return false unless passed
199
- end
200
- true
465
+ in_all?(condition_value, attribute_value, ctx, false)
466
+ when '$alli'
467
+ return false unless condition_value.is_a?(Array)
468
+
469
+ in_all?(condition_value, attribute_value, ctx, true)
201
470
  when '$exists'
202
471
  exists = !attribute_value.nil?
203
472
  if condition_value
@@ -208,13 +477,42 @@ module Growthbook
208
477
  when '$type'
209
478
  condition_value == get_type(attribute_value)
210
479
  when '$not'
211
- !eval_condition_value(condition_value, attribute_value)
480
+ negate(eval_condition_value(condition_value, attribute_value, ctx))
212
481
  else
213
- false
482
+ # Keys this SDK knows, but on a condition rather than on a value, where
483
+ # they never match. Any other operator is unsupported.
484
+ CONDITION_OPERATORS.include?(operator) ? false : nil
485
+ end
486
+ end
487
+
488
+ def self.in_all?(condition_values, attribute_value, ctx, insensitive)
489
+ return false unless attribute_value.is_a? Array
490
+
491
+ # @type var result: bool?
492
+ result = true
493
+ condition_values.each do |cond|
494
+ # @type var passed: bool?
495
+ passed = false
496
+ attribute_value.each do |attr|
497
+ r = eval_condition_value(cond, attr, ctx, insensitive: insensitive)
498
+ if r == true
499
+ passed = true
500
+ break
501
+ end
502
+ passed = nil if r.nil?
503
+ end
504
+ return false if passed == false
505
+
506
+ result = nil if passed.nil?
214
507
  end
508
+ result
215
509
  end
216
510
 
217
511
  def self.padded_version_string(input)
512
+ # Format numbers like JS does (2.0 -> "2", 0.000002 -> "0.000002"); treat empty input as version 0
513
+ input = Util.js_number_to_s(input) if number?(input)
514
+ input = '0' if input.empty?
515
+
218
516
  # Remove build info and leading `v` if any
219
517
  # Split version into parts (both core version numbers and pre-release tags)
220
518
  # "v1.2.3-rc.1+build123" -> ["1","2","3","rc","1"]
@@ -231,10 +529,19 @@ module Growthbook
231
529
  end.join('-')
232
530
  end
233
531
 
234
- def self.in?(actual, expected)
235
- return expected.include?(actual) unless actual.is_a?(Array)
532
+ # Like JS Array#includes: 1 matches 1.0, and an array or object never
533
+ # matches (JS compares them by reference). An array attribute matches if any
534
+ # of its items does. Array#include? compares with ==, which matches JS for
535
+ # everything but arrays and objects.
536
+ def self.in?(actual, expected, insensitive: false)
537
+ expected ||= []
538
+
539
+ fold = ->(val) { insensitive && val.is_a?(String) ? val.downcase : val }
540
+ folded_expected = insensitive ? expected.map { |exp| fold.call(exp) } : expected
541
+ includes = ->(val) { !val.is_a?(Array) && !val.is_a?(Hash) && folded_expected.include?(fold.call(val)) }
542
+ return actual.any? { |el| includes.call(el) } if actual.is_a?(Array)
236
543
 
237
- (actual & expected).any?
544
+ includes.call(actual)
238
545
  end
239
546
 
240
547
  # Sets $VERBOSE for the duration of the block and back to its original