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 +4 -4
- data/lib/growthbook/conditions.rb +409 -102
- data/lib/growthbook/context.rb +70 -20
- data/lib/growthbook/feature_repository.rb +24 -2
- data/lib/growthbook/feature_result.rb +10 -2
- data/lib/growthbook/feature_rule.rb +10 -1
- data/lib/growthbook/fnv.rb +5 -2
- data/lib/growthbook/inline_experiment.rb +7 -1
- data/lib/growthbook/payload_reducer.rb +278 -0
- data/lib/growthbook/util.rb +36 -0
- data/lib/growthbook.rb +47 -0
- metadata +35 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a9b291e75da702bbfbb6e2c8b11ff3c58ae5a0cd3e698d653a1e2be9839c191c
|
|
4
|
+
data.tar.gz: 84ffb3e30db50ffaefebabd45763632ae991777c8971834e771e07228d0e2080
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
current
|
|
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
|
-
|
|
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
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
320
|
+
result
|
|
105
321
|
end
|
|
106
322
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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.
|
|
349
|
+
def self.eval_compare_operator(operator, attribute_value, condition_value)
|
|
120
350
|
case operator
|
|
121
|
-
when '$
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 '$
|
|
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
|
|
463
|
+
return false unless condition_value.is_a?(Array)
|
|
192
464
|
|
|
193
|
-
condition_value
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
480
|
+
negate(eval_condition_value(condition_value, attribute_value, ctx))
|
|
212
481
|
else
|
|
213
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
|
544
|
+
includes.call(actual)
|
|
238
545
|
end
|
|
239
546
|
|
|
240
547
|
# Sets $VERBOSE for the duration of the block and back to its original
|