eight_ball 3.4.0 → 3.5.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: f7013714e1fab00bbf56af0703c7212800aa3d664ebf34f6f296353c9f5e2c42
4
- data.tar.gz: d670e0a056df163ad933f1afa757fb80ba2640bd728349ec830103f40ef921a9
3
+ metadata.gz: f1ad76a62094723d7f3b5f7bc879100b75bd9170ff804201ebd8603188dd32db
4
+ data.tar.gz: 5c417107f3c8becefd977bde7ccffc46cba30bc814664ad7a6ad7d1bf8541e25
5
5
  SHA512:
6
- metadata.gz: 2b86c9cd885352a5e60b6e1b699edfb476654eed89c21a1900f6220b45d2ce7bbd541062eab89599140de35c003770a8aacfe17f37180b56262321b4b1cb94f1
7
- data.tar.gz: cd12202dda5daa1d284e501c9c4a3a5a214f5fd0dd6908aacc521e69e67713eb82494f62418141715b4cd8b953182a721dd705064baaf120a0a81319aee0a600
6
+ metadata.gz: f1921b19cd4786696d841ffd90a708dc9ac0d2f5ee6e61026fbf8727574d742dceae3c07d62ef50c92d3c585592b8a7f07574d3cd6c6c0a6a93c8c6734365056
7
+ data.tar.gz: c8724604fc631cd7573ffec63692471330bf712ce35345f19844e4d9af0fb4d98870103c2be354a38fcfc7344e1be105158ba772a164ea2428fa0de1033e19bc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.5.0]
4
+
5
+ - Fix `Feature#enabled?` so a direction's conditions are evaluated as the documented OR. `any_satisfied?` used `return` inside an `any?` block, which exits the method, so the first condition without a parameter became the whole verdict and later conditions were never consulted. A `never` ahead of a matching `list` evaluated to false in `enabledFor`, and a `never` ahead of a matching entry in `disabledFor` stopped that entry from disabling the feature.
6
+
7
+ Behaviour is unchanged for any direction holding a single condition, and for any direction whose parameterless condition is satisfied (an `always` still short-circuits the direction, so nothing after it is reached).
8
+
9
+ **Upgrade note.** The affected shape is a direction holding an UNSATISFIED parameterless condition (in practice a `never`) followed by a parameterized one. Those later conditions are now evaluated, which has two consequences. First, the verdict can change in either direction, and in both cases the later condition starts doing what it says: an `enabledFor` allow entry begins enabling (`false` to `true`), and a `disabledFor` deny entry begins disabling (`true` to `false`). Second, evaluation can now raise where it previously answered a boolean, because the later condition's parameter is required exactly as it would be if that condition stood alone: `ArgumentError, "Missing parameter ..."` when the bag lacks the key, and for a `range` whose bag value is the wrong type, `ArgumentError, "comparison of String with 1 failed"`. Treating an absent parameter as unsatisfied instead was rejected deliberately, because it would let a `disabledFor` entry silently stop disabling a feature when a caller omits the key. Audit any definition with more than one condition in a direction before upgrading.
10
+
11
+ - Reject any parameterized condition (`list`, `range`, `percentage`) that has no `parameter`, or only whitespace. Such a condition evaluates against a subject value it has no way to look up, and previously raised `wrong number of arguments` on every evaluation for every caller. It is now treated as unbuildable, so the owning feature fails closed like any other invalid entry. `always` and `never` take no value and are unaffected.
12
+
13
+ - Ignore `nil` entries in a Feature's condition lists instead of raising `NoMethodError` while evaluating. The marshaller already dropped them when writing, so reading now matches writing.
14
+
15
+ - Derive `un_evaluable?` from the conditions rather than holding it only as state set after construction, so a Feature rebuilt from another's conditions stays failed closed instead of silently becoming enabled again.
16
+
3
17
  ## [3.4.0]
4
18
 
5
19
  - Add an opt-in `coerce` boolean to the `list` condition. When `true`, values and the tested input are compared as strings, so a list authored with integers matches a string caller (and vice versa). Defaults to exact-type matching, so existing definitions are unchanged; the flag is serialized only when enabled.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- eight_ball (3.4.0)
4
+ eight_ball (3.5.0)
5
5
  awrence (~> 1.1)
6
6
  logger (~> 1.7)
7
7
  plissken (~> 1.2)
data/README.md CHANGED
@@ -55,8 +55,8 @@ features = marshaller.unmarshall json_input
55
55
  EightBall.provider = EightBall::Providers::Static.new features
56
56
 
57
57
  # Away you go
58
- EightBall.enabled? "Feature1", { accountId: 4 } # true
59
- EightBall.enabled? "Feature1", { accountId: 2 } # false
58
+ EightBall.enabled? "Feature1", { account_id: 4 } # true
59
+ EightBall.enabled? "Feature1", { account_id: 2 } # false
60
60
  ```
61
61
 
62
62
  More examples [here](examples)
@@ -81,7 +81,7 @@ A Condition must either be `true` or `false`. It describes when a Feature is ena
81
81
  - [List](lib/eight_ball/conditions/list.rb): This condition is satisfied if the given value belongs to its list of accepted values.
82
82
  - [Never](lib/eight_ball/conditions/never.rb): This condition is never satisfied.
83
83
  - [Range](lib/eight_ball/conditions/range.rb): This condition is satisfied if the given value is within the specified range (inclusive).
84
- - [Percentage](lib/eight_ball/conditions/percentage.rb): This condition is satisfied for a deterministic, sticky subset of subjects sized to `percentage` percent. Bucketing is salted by the flag name so a subject is decorrelated across flags. Wire form: `{"type":"percentage","parameter":"account_id","percentage":<0..100>}`.
84
+ - [Percentage](lib/eight_ball/conditions/percentage.rb): This condition is satisfied for a deterministic, sticky subset of subjects sized to `percentage` percent. Bucketing is salted by the flag name so a subject is decorrelated across flags. `parameter` is required. Wire form: `{"type":"percentage","percentage":<0..100>,"parameter":"account_id"}`.
85
85
 
86
86
  #### List value coercion
87
87
 
@@ -13,12 +13,13 @@ module EightBall::Conditions
13
13
  # @param [Hash] options
14
14
  # @option options [Integer] :percentage Integer 0..100. 0 is never
15
15
  # satisfied; 100 is always satisfied.
16
- # @option options [String] :parameter The name of the parameter this Condition
17
- # was created for (eg. "account_id").
16
+ # @option options [String] :parameter Required. The parameter to bucket on (eg. "account_id").
18
17
  def initialize(options = {})
19
18
  options ||= {}
20
19
 
21
20
  raise ArgumentError, 'Missing value for percentage' if options[:percentage].nil?
21
+ # Without a parameter every subject shares one salt, so the flag ships to 0 or 100 percent.
22
+ raise ArgumentError, 'Missing value for parameter' if options[:parameter].to_s.strip.empty?
22
23
 
23
24
  percentage = options[:percentage]
24
25
  percentage = percentage.to_i if percentage.is_a?(Float) && percentage.to_i == percentage
@@ -20,10 +20,12 @@ module EightBall
20
20
  # feature = EightBall::Feature.new 'feature1', EightBall::Conditions::Always
21
21
  def initialize(name, enabled_for = [], disabled_for = [], metadata: nil)
22
22
  @name = name
23
- @enabled_for = inject_flag_name(Array(enabled_for))
24
- @disabled_for = inject_flag_name(Array(disabled_for))
23
+ # A nil entry has no #parameter and would raise while evaluating.
24
+ @enabled_for = inject_flag_name(Array(enabled_for).compact)
25
+ @disabled_for = inject_flag_name(Array(disabled_for).compact)
25
26
  @metadata = metadata
26
- @un_evaluable = false
27
+ # Derived, so a Feature rebuilt from these conditions stays un-evaluable.
28
+ @un_evaluable = (@enabled_for + @disabled_for).any? { |c| c.is_a?(EightBall::Conditions::Opaque) }
27
29
  end
28
30
 
29
31
  # "EightBall, is this Feature enabled?"
@@ -82,7 +84,8 @@ module EightBall
82
84
 
83
85
  def any_satisfied?(conditions, parameters)
84
86
  conditions.any? do |condition|
85
- return condition.satisfied? if condition.parameter.nil?
87
+ # next, not return: a return exits the method and decides the whole direction.
88
+ next condition.satisfied? if condition.parameter.nil?
86
89
 
87
90
  value = parameters[condition.parameter.to_sym]
88
91
  raise ArgumentError, "Missing parameter #{condition.parameter}" if value.nil?
@@ -130,10 +130,17 @@ module EightBall::Marshallers
130
130
 
131
131
  condition_class = EightBall::Conditions.by_name condition[:type]
132
132
  return EightBall::Conditions::Opaque.new(condition) if condition_class.nil?
133
+ # Nothing to match against without a parameter, so fail the flag closed.
134
+ return EightBall::Conditions::Opaque.new(condition) if requires_parameter?(condition_class) && condition[:parameter].to_s.strip.empty?
133
135
 
134
136
  condition_class.new condition
135
137
  rescue StandardError
136
138
  EightBall::Conditions::Opaque.new(condition)
137
139
  end
140
+
141
+ # A condition that evaluates against a subject value is one that needs a parameter.
142
+ def requires_parameter?(condition_class)
143
+ condition_class.instance_method(:satisfied?).arity.positive?
144
+ end
138
145
  end
139
146
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EightBall
4
- VERSION = '3.4.0'
4
+ VERSION = '3.5.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eight_ball
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.4.0
4
+ version: 3.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rewind.io