flippant 0.6.0 → 0.9.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
- SHA1:
3
- metadata.gz: 8c259d40ca6b2cbd3305d2ab5a1c2ba425794886
4
- data.tar.gz: 913b5c2043ae9b6c6c2ecea20666f0c82e3dc444
2
+ SHA256:
3
+ metadata.gz: eb2f65a02966b9232561570498e893b0905ca3685b6e826b79376d7ede5c76d5
4
+ data.tar.gz: e6c8c1487f5ef80b0f467305076c36aca091fbcd38d1953fe4e03de6ea8dcb87
5
5
  SHA512:
6
- metadata.gz: 627e7caa8606d1d7b002f513553be1a5e0bea1647ae35d111ccc71962b496fb2b0f0b93669233fe67e97e283321a41dbb54f03d545a09b35c3e8fd9adc1041ce
7
- data.tar.gz: 919ce1da13ee2f5e7353eb0f96b892aea75bf006b148cb125e0cbbed25ffec8c421a3658df4a9766a8210f741037a4f71744d6447cdd5ee9a16160546f508aa0
6
+ metadata.gz: afc9ead23ec2566f9750802e73c6895a8ac99aa75b7f6bd9295b7c48d0aecd5b3d839eacf83ed30637c09a8f1c2cff34c0e82623a3edd3d2b0d7892de0093bc8
7
+ data.tar.gz: dcb7abfe21f01a19d900c3933ec43d5b62a6a2be34f6c054b6ed6abc0ed9f8aa82567306730e52500fb86fba05b5d18e0ae83754cf433c96d8110c63b47748c3
data/.rubocop.yml CHANGED
@@ -4,12 +4,26 @@ AllCops:
4
4
  - "vendor/**/*"
5
5
  TargetRubyVersion: 2.3
6
6
 
7
+ Layout/IndentArray:
8
+ Enabled: false
9
+
10
+ Layout/SpaceInsideHashLiteralBraces:
11
+ EnforcedStyle: no_space
12
+ EnforcedStyleForEmptyBraces: no_space
13
+
7
14
  Metrics/AbcSize:
8
15
  Max: 20
9
16
 
17
+ Metrics/BlockLength:
18
+ Exclude:
19
+ - "spec/**/*"
20
+
10
21
  Metrics/ClassLength:
11
22
  Max: 200
12
23
 
24
+ Metrics/LineLength:
25
+ Max: 96
26
+
13
27
  Metrics/MethodLength:
14
28
  Max: 20
15
29
 
@@ -40,14 +54,3 @@ Style/PercentLiteralDelimiters:
40
54
 
41
55
  Style/StringLiterals:
42
56
  EnforcedStyle: double_quotes
43
-
44
- Style/SpaceInsideHashLiteralBraces:
45
- EnforcedStyle: no_space
46
- EnforcedStyleForEmptyBraces: no_space
47
-
48
- Style/IndentArray:
49
- Enabled: false
50
-
51
- Metrics/BlockLength:
52
- Exclude:
53
- - "spec/**/*"
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 2.5.0
data/.travis.yml CHANGED
@@ -1,7 +1,13 @@
1
1
  sudo: false
2
2
  language: ruby
3
3
  rvm:
4
- - 2.3.0
5
- before_install: gem install bundler -v 1.13.6
4
+ - 2.3.6
5
+ - 2.4.3
6
+ - 2.5.0
6
7
  services:
8
+ - postgresql
7
9
  - redis-server
10
+ before_script:
11
+ - psql -c "CREATE DATABASE flippant_test;" -U postgres
12
+ addons:
13
+ postgresql: "9.6"
data/CHANGELOG.md CHANGED
@@ -1,4 +1,30 @@
1
- ## v0.6.0 - 2017-08-08
1
+ ## v0.9.0 2018-03-20
2
+
3
+ ### Enhancements
4
+
5
+ * [Flippant] - Add a new Postgres adatper, backed by PG and ActiveRecord pools.
6
+ * [Flippant] - Add `setup` to facilitate adapter setup (i.e. Postgres).
7
+ * [Flippant] - Modify `enable` and `disable` to prevent duplicate values and
8
+ operate atomically without a transaction.
9
+ * [Flippant] - Add `dump/1` and `load/1` functions for backups and portability.
10
+
11
+ ### Changes
12
+
13
+ * [Flippant::Adapter] - Values are no longer guaranteed to be sorted. Some
14
+ adapters guarantee sorting, but race conditions prevent it in the Postgres
15
+ adapter, so it is no longer guaranteed.
16
+
17
+ ## v0.6.0 2017-08-08
18
+
19
+ ### Enhancements
20
+
21
+ * [Flippant] - Merge additional values when enabling features. This prevents
22
+ clobbering existing values in "last write wins" situations.
23
+ * [Flippant] - Support enabling or disabling of individual values. This makes it
24
+ possible to remove a single value from a group's rules.
25
+ * [Flippant] - Add `rename/2` for renaming existing features.
26
+ * [Flippant] - Add `exists?/1` for checking whether a feature exists at all,
27
+ and `exists?/2` for checking whether a feature exists for a particular group.
2
28
 
3
29
  ### Changes
4
30
 
@@ -7,12 +33,12 @@
7
33
  exist. This forces an order of defining groups before enabling features, but
8
34
  it will prevent trying to register groups that don't exist or with typos.
9
35
 
10
- ## v0.5.1 - 2017-01-26
36
+ ## v0.5.1 2017-01-26
11
37
 
12
38
  ### Changes
13
39
 
14
40
  * [Flippant::Adapter::Redis] - Constructor uses keyword arguments.
15
41
 
16
- ## v0.5.0 - 2017-01-24
42
+ ## v0.5.0 2017-01-24
17
43
 
18
44
  * Initial release, largely ported from sorentwo/flippant, the Elixir version.
data/README.md CHANGED
@@ -1,41 +1,186 @@
1
1
  # Flippant
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/flippant`. To experiment with that code, run `bin/console` for an interactive prompt.
4
-
5
- TODO: Delete this and the text above, and describe your gem
3
+ Fast feature toggling for ruby applications, backed by Redis.
6
4
 
7
5
  ## Installation
8
6
 
9
7
  Add this line to your application's Gemfile:
10
8
 
11
9
  ```ruby
12
- gem 'flippant-rb'
10
+ gem 'flippant'
13
11
  ```
14
12
 
15
- And then execute:
13
+ ## Usage
14
+
15
+ Flippant composes three constructs to determine whether a feature is enabled:
16
16
 
17
- $ bundle
17
+ * Actors - An actor can be any value, but typically it is a `User` or
18
+ some other object representing a user.
19
+ * Groups - Groups are used to identify and qualify actors. For example,
20
+ "everybody", "nobody", "admins", "staff", "testers" could all be groups names.
21
+ * Rules - Rules represent individual features which are evaluated against actors
22
+ and groups. For example, "search", "analytics", "super-secret-feature" could
23
+ all be rule names.
18
24
 
19
- Or install it yourself as:
25
+ Group names may be either strings or symbols.
20
26
 
21
- $ gem install flippant-rb
27
+ Let's walk through setting up a few example groups and rules. You'll want to
28
+ establish groups at startup, as they aren't likely to change (and defining
29
+ functions from a web interface isn't wise).
22
30
 
23
- ## Usage
31
+ ### Groups
24
32
 
25
- TODO: Write usage instructions here
33
+ First, a group that nobody can belong to. This is useful for disabling a feature
34
+ without deleting it:
35
+
36
+ ```ruby
37
+ Flippant.register("nobody", ->(actor, _) { false })
38
+ ```
26
39
 
27
- ## Development
40
+ Now the opposite, a group that everybody can belong to:
28
41
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
42
+ ```ruby
43
+ Flippant.register("everybody", ->(actor, _) { true })
44
+ ```
30
45
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
46
+ To be more exclusive and define staff-only features we need a "staff" group:
32
47
 
33
- ## Contributing
48
+ ```ruby
49
+ Flippant.register("staff", ->(actor, _) { actor.staff? })
50
+ ```
34
51
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/flippant-rb. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
52
+ Lastly, we'll roll out a feature out to a percentage of the actors:
36
53
 
54
+ ```ruby
55
+ Flippant.register("adopters", ->(actor, buckets) { buckets.include?(actor.id % 10) })
56
+ ```
37
57
 
38
- ## License
58
+ To tidy up a bit, we can define the registered group detection functions in a separate module.
39
59
 
40
- The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
60
+ ```ruby
61
+ module FeatureGroups
62
+ def self.premium_subscriber?(actor, _)
63
+ actor.premium_subscriber?
64
+ end
65
+
66
+ def self.allowed_user?(actor, allowed_ids)
67
+ allowed_ids.include?(actor.id)
68
+ end
69
+ end
70
+
71
+ Flippant.register("premium_subscriber", &FeatureGroups.method(:premium_subscriber?))
72
+ Flippant.register("allowed_user", &FeatureGroups.method(:allowed_user?))
73
+ ```
74
+
75
+
76
+ With some core groups defined we now can set up some rules.
77
+
78
+ ### Rules
79
+
80
+ Rules are comprised of a name, a group, and an optional set of values. Starting
81
+ with a simple example that builds on the groups we have already created, we'll
82
+ enable the "search" feature:
83
+
84
+ ```ruby
85
+ # Any staff can use the "search" feature
86
+ Flippant.enable("search", "staff")
87
+
88
+ # 30% of "adopters" can use the "search" feature as well
89
+ Flippant.enable("search", "adopters", [0, 1, 2])
90
+ ```
91
+
92
+ Because rules are only built of binaries and simple data they can be defined or
93
+ refined at runtime. In fact, this is a crucial part of feature toggling. With a
94
+ web interface rules can be added, removed, or modified.
95
+
96
+ ```ruby
97
+ # Turn search off for adopters
98
+ Flippant.disable("search", "adopters")
99
+
100
+ # On second thought, enable it again for 10%
101
+ Flippant.enable("search", "adopters", [3])
102
+ ```
103
+
104
+ With a set of groups and rules defined we can check whether a feature is
105
+ enabled for a particular actor:
106
+
107
+ ```ruby
108
+ class User
109
+ attr_accessor :id, :is_staff
110
+
111
+ def initialize(id, is_staff)
112
+ @id = id
113
+ @is_staff = is_staff
114
+ end
115
+
116
+ def staff?
117
+ @is_staff
118
+ end
119
+ end
120
+
121
+ staff_user = User.new(1, true)
122
+ early_user = User.new(2, false)
123
+ later_user = User.new(3, false)
124
+
125
+ Flippant.enabled?("search", staff_user) #=> true, staff
126
+ Flippant.enabled?("search", early_user) #=> false, not an adopter
127
+ Flippant.enabled?("search", later_user) #=> true, is an adopter
128
+ ```
129
+
130
+ If an actor qualifies for multiple groups and *any* of the rules evaluate to
131
+ true that feature will be enabled for them. Think of the "nobody" and
132
+ "everybody" groups that were defined earlier:
133
+
134
+ ```ruby
135
+ Flippant.enable("search", "everybody")
136
+ Flippant.enable("search", "nobody")
137
+
138
+ Flippant.enabled?("search", User.new) #=> true
139
+ ```
140
+
141
+ ## Breakdown
142
+
143
+ Evaluating rules requires a round trip to the database. Clearly, with a lot of
144
+ rules it is inefficient to evaluate each one individually. There is a function
145
+ to help with this exact scenario:
146
+
147
+ ```ruby
148
+ Flippant.enable("search", "staff")
149
+ Flippant.enable("delete", "everybody")
150
+ Flippant.enable("invite", "nobody")
151
+
152
+ user = User.new(1, true)
153
+ Flippant.breakdown(user) #=> {
154
+ "search" => true,
155
+ "delete" => true,
156
+ "invite" => false
157
+ }
158
+ ```
159
+
160
+ The breakdown is a simple hash of string keys to boolean values. This is
161
+ extremely handy for single page applications where you can serialize the
162
+ breakdown on boot or send it back from an endpoint as JSON.
163
+
164
+ ## Configuration
165
+
166
+ Both Redis and Memory adapters are available for Flippant's registry storage. Memory
167
+ is the default.
168
+
169
+ The Memory adapter behaves identically to the Redis adapter, but will clear out
170
+ its registry whenever the application is reloaded, so it may be especially useful
171
+ in testing.
172
+
173
+ You may want to change this to Redis in production by overriding the `adapter` setting.
174
+
175
+ ```ruby
176
+ # In Rails, for instance, add this to `config/initializers/flippant.rb`:
177
+ Flippant.adapter = if Rails.env.test?
178
+ Flippant::Adapter::Memory.new
179
+ else
180
+ Flippant::Adapter::Redis.new
181
+ end
182
+ ```
183
+
184
+ ## License
41
185
 
186
+ MIT License, see [LICENSE.txt](LICENSE.txt) for details.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark/ips"
4
+ require "flippant"
5
+ require "active_record"
6
+
7
+ ActiveRecord::Base.establish_connection("postgres:///flippant_test")
8
+
9
+ memory = Flippant::Adapter::Memory.new
10
+ postgres = Flippant::Adapter::Postgres.new
11
+ redis = Flippant::Adapter::Redis.new
12
+
13
+ memory_enabled = lambda do
14
+ Flippant.adapter = memory unless Flippant.adapter == memory
15
+
16
+ Flippant.enabled?("search", nil)
17
+ end
18
+
19
+ postgres_enabled = lambda do
20
+ Flippant.adapter = postgres unless Flippant.adapter == postgres
21
+
22
+ Flippant.enabled?("search", nil)
23
+ end
24
+
25
+ redis_enabled = lambda do
26
+ Flippant.adapter = redis unless Flippant.adapter == redis
27
+
28
+ Flippant.enabled?("search", nil)
29
+ end
30
+
31
+ Flippant.configure do |config|
32
+ config.adapter = Flippant::Adapter::Postgres.new
33
+ end
34
+
35
+ Flippant.register("everybody", ->(_, _) { true })
36
+ Flippant.enable("search", "everybody")
37
+
38
+ Benchmark.ips do |x|
39
+ x.report "memory:enabled?", &memory_enabled
40
+ x.report "postgres:enabled?", &postgres_enabled
41
+ x.report "redis:enabled?", &redis_enabled
42
+ x.compare!
43
+ end
data/flippant.gemspec CHANGED
@@ -1,19 +1,17 @@
1
- # coding: utf-8
2
-
3
- lib = File.expand_path("../lib", __FILE__)
1
+ lib = File.expand_path("lib", __dir__)
4
2
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
3
  require "flippant/version"
6
4
 
7
5
  Gem::Specification.new do |spec|
8
- spec.name = "flippant"
9
- spec.version = Flippant::VERSION
10
- spec.authors = ["Parker Selbert"]
11
- spec.email = ["parker@sorentwo.com"]
6
+ spec.name = "flippant"
7
+ spec.version = Flippant::VERSION
8
+ spec.authors = ["Parker Selbert"]
9
+ spec.email = ["parker@sorentwo.com"]
12
10
 
13
- spec.summary = "Fast feature toggling for applications"
11
+ spec.summary = "Fast feature toggling for applications"
14
12
  spec.description = "Fast feature toggling for applications"
15
- spec.homepage = "https://github.com/sorentwo/flippant-rb"
16
- spec.license = "MIT"
13
+ spec.homepage = "https://github.com/sorentwo/flippant-rb"
14
+ spec.license = "MIT"
17
15
 
18
16
  spec.files = `git ls-files -z`.split("\x0").reject do |f|
19
17
  f.match(%r{^spec/})
@@ -22,9 +20,12 @@ Gem::Specification.new do |spec|
22
20
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
21
  spec.require_paths = ["lib"]
24
22
 
23
+ spec.add_development_dependency "activerecord", "~> 5.1"
24
+ spec.add_development_dependency "benchmark-ips"
25
25
  spec.add_development_dependency "bundler"
26
+ spec.add_development_dependency "pg", "~> 1.0"
26
27
  spec.add_development_dependency "rake"
27
- spec.add_development_dependency "rspec", "~> 3.2"
28
- spec.add_development_dependency "redis", "~> 3.3"
29
- spec.add_development_dependency "rubocop", "~> 0.47"
28
+ spec.add_development_dependency "redis", "~> 4.0"
29
+ spec.add_development_dependency "rspec", "~> 3.7"
30
+ spec.add_development_dependency "rubocop", "~> 0.52"
30
31
  end
@@ -1,36 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
4
+
3
5
  module Flippant
4
6
  module Adapter
5
7
  class Memory
6
- attr_reader :table
8
+ attr_reader :monitor, :table
7
9
 
8
10
  def initialize
11
+ @monitor = Monitor.new
12
+
9
13
  clear
10
14
  end
11
15
 
12
- def add(feature)
13
- table[normalize(feature)] ||= {}
16
+ def setup
17
+ true
14
18
  end
15
19
 
16
- def remove(feature)
17
- table.delete(feature)
20
+ def add(feature)
21
+ table[feature] ||= {}
18
22
  end
19
23
 
20
- def enable(feature, group, values = [])
21
- fkey = normalize(feature)
22
- gkey = group.to_s
24
+ def breakdown(actor = nil)
25
+ return table if actor.nil?
23
26
 
24
- Mutex.new.synchronize do
25
- table[fkey][gkey] ||= []
26
- table[fkey][gkey] = (table[fkey][gkey] | values).sort
27
+ table.each_with_object({}) do |(feature, _), memo|
28
+ memo[feature] = enabled?(feature, actor)
27
29
  end
28
30
  end
29
31
 
32
+ def clear
33
+ @table = Hash.new { |hash, key| hash[key] = {} }
34
+ end
35
+
30
36
  def disable(feature, group, values = [])
31
- rules = table[normalize(feature)]
37
+ rules = table[feature]
32
38
 
33
- Mutex.new.synchronize do
39
+ monitor.synchronize do
34
40
  if values.any?
35
41
  remove_values(rules, group, values)
36
42
  else
@@ -39,24 +45,25 @@ module Flippant
39
45
  end
40
46
  end
41
47
 
42
- def rename(old_feature, new_feature)
43
- old_feature = normalize(old_feature)
44
- new_feature = normalize(new_feature)
48
+ def enable(feature, group, values = [])
49
+ fkey = feature
50
+ gkey = group.to_s
45
51
 
46
- table[new_feature] = table.delete(old_feature)
52
+ monitor.synchronize do
53
+ table[fkey][gkey] ||= []
54
+ table[fkey][gkey] = (table[fkey][gkey] | values).sort
55
+ end
47
56
  end
48
57
 
49
58
  def enabled?(feature, actor, registered = Flippant.registered)
50
- table[normalize(feature)].any? do |group, values|
59
+ table[feature].any? do |group, values|
51
60
  if (block = registered[group.to_s])
52
61
  block.call(actor, values)
53
62
  end
54
63
  end
55
64
  end
56
65
 
57
- def exists?(feature, group = nil)
58
- feature = normalize(feature)
59
-
66
+ def exists?(feature, group)
60
67
  if group.nil?
61
68
  table.key?(feature)
62
69
  else
@@ -74,24 +81,29 @@ module Flippant
74
81
  end
75
82
  end
76
83
 
77
- def breakdown(actor = nil)
78
- return table if actor.nil?
79
-
80
- table.each_with_object({}) do |(feature, _), memo|
81
- memo[feature] = enabled?(feature, actor)
84
+ def load(loaded)
85
+ monitor.synchronize do
86
+ loaded.each do |feature, rules|
87
+ rules.each do |group, values|
88
+ table[feature][group] = values
89
+ end
90
+ end
82
91
  end
83
92
  end
84
93
 
85
- def clear
86
- @table = Hash.new { |hash, key| hash[key] = {} }
94
+ def remove(feature)
95
+ table.delete(feature)
87
96
  end
88
97
 
89
- private
98
+ def rename(old_feature, new_feature)
99
+ old_feature = old_feature
100
+ new_feature = new_feature
90
101
 
91
- def normalize(feature)
92
- feature.to_s.downcase.strip
102
+ table[new_feature] = table.delete(old_feature)
93
103
  end
94
104
 
105
+ private
106
+
95
107
  def remove_group(rules, to_remove)
96
108
  rules.reject! { |(group, _)| group == to_remove.to_s }
97
109
  end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "pg"
5
+
6
+ module Flippant
7
+ module Adapter
8
+ class Postgres
9
+ DEFAULT_TABLE = "flippant_features"
10
+
11
+ attr_reader :pool, :table
12
+
13
+ def initialize(pool: ActiveRecord::Base.connection_pool, table: DEFAULT_TABLE)
14
+ @pool = pool
15
+ @table = table
16
+ end
17
+
18
+ def add(feature)
19
+ exec("INSERT INTO #{table} (name) VALUES ($1) ON CONFLICT (name) DO NOTHING", [feature])
20
+ end
21
+
22
+ def breakdown(actor = :all)
23
+ result = exec("SELECT jsonb_object_agg(name, rules) FROM #{table}")
24
+ object = JSON.parse(result.values.flatten.first || "{}")
25
+
26
+ object.each_with_object({}) do |(fkey, rules), memo|
27
+ memo[fkey] = actor == :all ? rules : Rules.enabled_for_actor?(rules, actor)
28
+ end
29
+ end
30
+
31
+ def clear
32
+ exec("TRUNCATE #{table} RESTART IDENTITY")
33
+ end
34
+
35
+ def disable(feature, group, values)
36
+ if values.empty?
37
+ exec("UPDATE #{table} SET rules = rules - $1 WHERE name = $2", [group, feature])
38
+ else
39
+ command = <<~SQL
40
+ UPDATE #{table} SET rules = jsonb_set(rules, $1, array_to_json(
41
+ ARRAY(
42
+ SELECT UNNEST(ARRAY(SELECT jsonb_array_elements(COALESCE(rules#>$1, '[]'::jsonb))))
43
+ EXCEPT
44
+ SELECT UNNEST(ARRAY(SELECT jsonb_array_elements($2)))
45
+ )
46
+ )::jsonb)
47
+ WHERE name = $3
48
+ SQL
49
+
50
+ exec(command, [encode_array([group]), encode_json(values), feature])
51
+ end
52
+ end
53
+
54
+ def enable(feature, group, values)
55
+ command = <<~SQL
56
+ INSERT INTO #{table} AS t (name, rules) VALUES ($1, $2)
57
+ ON CONFLICT (name) DO UPDATE
58
+ SET rules = jsonb_set(t.rules, $3, array_to_json(
59
+ ARRAY(
60
+ SELECT DISTINCT(UNNEST(ARRAY(
61
+ SELECT jsonb_array_elements(COALESCE(t.rules#>$3, '[]'::jsonb))
62
+ ) || $4))
63
+ )
64
+ )::jsonb)
65
+ SQL
66
+
67
+ exec(command, [feature,
68
+ encode_json(group => values),
69
+ encode_array([group]),
70
+ encode_array(values)])
71
+ end
72
+
73
+ def enabled?(feature, actor)
74
+ result = exec("SELECT rules FROM #{table} WHERE name = $1", [feature])
75
+ object = JSON.parse(result.values.flatten.first || "[]")
76
+
77
+ Rules.enabled_for_actor?(object, actor)
78
+ end
79
+
80
+ def exists?(feature, group = nil)
81
+ result =
82
+ if group.nil?
83
+ exec("SELECT EXISTS (SELECT 1 FROM #{table} WHERE name = $1)",
84
+ [feature])
85
+ else
86
+ exec("SELECT EXISTS (SELECT 1 FROM #{table} WHERE name = $1 " \
87
+ "AND rules ? $2)",
88
+ [feature, group])
89
+ end
90
+
91
+ result.values.first == [true]
92
+ end
93
+
94
+ def features(group = :all)
95
+ result =
96
+ if group == :all
97
+ exec("SELECT name FROM #{table} ORDER BY name ASC")
98
+ else
99
+ exec("SELECT name FROM #{table} WHERE rules ? $1 ORDER BY name ASC", [group])
100
+ end
101
+
102
+ result.values.flatten
103
+ end
104
+
105
+ def load(loaded)
106
+ transaction do |client|
107
+ loaded.each do |feature, rules|
108
+ client.exec_params(
109
+ "INSERT INTO #{table} AS t (name, rules) VALUES ($1, $2)",
110
+ [feature, encode_json(rules)]
111
+ )
112
+ end
113
+ end
114
+ end
115
+
116
+ def rename(old_name, new_name)
117
+ transaction do |client|
118
+ client.exec_params("DELETE FROM #{table} WHERE name = $1",
119
+ [new_name])
120
+
121
+ client.exec_params("UPDATE #{table} SET name = $1 WHERE name = $2",
122
+ [new_name, old_name])
123
+ end
124
+ end
125
+
126
+ def remove(feature)
127
+ exec("DELETE FROM #{table} WHERE name = $1", [feature])
128
+ end
129
+
130
+ def setup
131
+ exec <<~SQL
132
+ CREATE TABLE IF NOT EXISTS #{table} (
133
+ name text NOT NULL CHECK (name <> ''),
134
+ rules jsonb NOT NULL DEFAULT '{}'::jsonb,
135
+ CONSTRAINT unique_name UNIQUE(name)
136
+ )
137
+ SQL
138
+ end
139
+
140
+ private
141
+
142
+ def conn
143
+ pool.with_connection do |connection|
144
+ client = connection.raw_connection
145
+
146
+ yield client
147
+ end
148
+ end
149
+
150
+ def exec(sql, params = [])
151
+ conn do |client|
152
+ if params.empty?
153
+ client.exec(sql)
154
+ else
155
+ client.exec_params(sql, params)
156
+ end
157
+ end
158
+ end
159
+
160
+ def transaction(&block)
161
+ conn do |client|
162
+ client.transaction(&block)
163
+ end
164
+ end
165
+
166
+ def encode_array(value)
167
+ PG::TextEncoder::Array.new.encode(value)
168
+ end
169
+
170
+ def encode_json(value)
171
+ PG::TextEncoder::JSON.new.encode(value)
172
+ end
173
+ end
174
+ end
175
+ end
@@ -9,35 +9,32 @@ module Flippant
9
9
 
10
10
  DEFAULT_KEY = "flippant-features"
11
11
 
12
- attr_reader :client, :key, :serializer
13
-
14
- def_delegators :serializer, :dump, :load
12
+ attr_reader :client, :set_key, :serializer
15
13
 
16
14
  def initialize(client: ::Redis.current,
17
- key: DEFAULT_KEY,
15
+ set_key: DEFAULT_KEY,
18
16
  serializer: Flippant.serializer)
19
17
  @client = client
20
- @key = key
21
18
  @serializer = serializer
19
+ @set_key = set_key
20
+ end
21
+
22
+ def setup
23
+ true
22
24
  end
23
25
 
24
26
  def add(feature)
25
- client.sadd(key, normalize(feature))
27
+ client.sadd(set_key, feature)
26
28
  end
27
29
 
28
- def remove(feature)
29
- client.multi do
30
- client.srem(key, feature)
31
- client.del(namespace(feature))
30
+ def breakdown(actor = nil)
31
+ features(:all).each_with_object({}) do |fkey, memo|
32
+ memo[fkey] = actor.nil? ? feature_rules(fkey) : enabled?(fkey, actor)
32
33
  end
33
34
  end
34
35
 
35
- def enable(feature, group, values = [])
36
- add(feature)
37
-
38
- change_values(namespace(feature), group) do |old|
39
- (old | values).sort
40
- end
36
+ def clear
37
+ client.smembers(set_key).each { |fkey| remove(fkey) }
41
38
  end
42
39
 
43
40
  def disable(feature, group, values = [])
@@ -54,32 +51,25 @@ module Flippant
54
51
  maybe_cleanup(feature)
55
52
  end
56
53
 
57
- def rename(old_feature, new_feature)
58
- old_feature = normalize(old_feature)
59
- new_feature = normalize(new_feature)
60
- old_namespaced = namespace(old_feature)
61
- new_namespaced = namespace(new_feature)
54
+ def enable(feature, group, values = [])
55
+ add(feature)
62
56
 
63
- client.watch(old_namespaced, new_namespaced) do
64
- client.multi do
65
- client.srem(key, old_feature)
66
- client.sadd(key, new_feature)
67
- client.rename(old_namespaced, new_namespaced)
68
- end
57
+ change_values(namespace(feature), group) do |old|
58
+ (old | values).sort
69
59
  end
70
60
  end
71
61
 
72
62
  def enabled?(feature, actor, registered = Flippant.registered)
73
63
  client.hgetall(namespace(feature)).any? do |group, values|
74
64
  if (block = registered[group])
75
- block.call(actor, load(values))
65
+ block.call(actor, serializer.load(values))
76
66
  end
77
67
  end
78
68
  end
79
69
 
80
- def exists?(feature, group = nil)
70
+ def exists?(feature, group)
81
71
  if group.nil?
82
- client.sismember(key, feature)
72
+ client.sismember(set_key, feature)
83
73
  else
84
74
  client.hexists(namespace(feature), group)
85
75
  end
@@ -87,7 +77,7 @@ module Flippant
87
77
 
88
78
  def features(filter = :all)
89
79
  if filter == :all
90
- client.smembers(key).sort
80
+ client.smembers(set_key).sort
91
81
  else
92
82
  features(:all).select do |fkey|
93
83
  client.hexists(namespace(fkey), filter)
@@ -95,14 +85,38 @@ module Flippant
95
85
  end
96
86
  end
97
87
 
98
- def breakdown(actor = nil)
99
- features(:all).each_with_object({}) do |fkey, memo|
100
- memo[fkey] = actor.nil? ? feature_rules(fkey) : enabled?(fkey, actor)
88
+ def load(loaded)
89
+ client.multi do
90
+ loaded.each do |feature, rules|
91
+ client.sadd(set_key, feature)
92
+
93
+ rules.each do |group, values|
94
+ client.hset(namespace(feature), group, serializer.dump(values))
95
+ end
96
+ end
101
97
  end
102
98
  end
103
99
 
104
- def clear
105
- client.smembers(key).each { |fkey| remove(fkey) }
100
+ def remove(feature)
101
+ client.multi do
102
+ client.srem(set_key, feature)
103
+ client.del(namespace(feature))
104
+ end
105
+ end
106
+
107
+ def rename(old_feature, new_feature)
108
+ old_feature = old_feature
109
+ new_feature = new_feature
110
+ old_namespaced = namespace(old_feature)
111
+ new_namespaced = namespace(new_feature)
112
+
113
+ client.watch(old_namespaced, new_namespaced) do
114
+ client.multi do
115
+ client.srem(set_key, old_feature)
116
+ client.sadd(set_key, new_feature)
117
+ client.rename(old_namespaced, new_namespaced)
118
+ end
119
+ end
106
120
  end
107
121
 
108
122
  private
@@ -111,26 +125,22 @@ module Flippant
111
125
  namespaced = namespace(feature)
112
126
 
113
127
  client.hgetall(namespaced).each_with_object({}) do |(key, val), memo|
114
- memo[key] = load(val)
128
+ memo[key] = serializer.load(val)
115
129
  end
116
130
  end
117
131
 
118
132
  def get_values(namespaced, group)
119
- load(client.hget(namespaced, group)) || []
133
+ serializer.load(client.hget(namespaced, group)) || []
120
134
  end
121
135
 
122
136
  def maybe_cleanup(feature)
123
137
  namespaced = namespace(feature)
124
138
 
125
- client.srem(key, feature) if client.hkeys(namespaced).empty?
139
+ client.srem(set_key, feature) if client.hkeys(namespaced).empty?
126
140
  end
127
141
 
128
142
  def namespace(feature)
129
- "#{key}-#{normalize(feature)}"
130
- end
131
-
132
- def normalize(feature)
133
- feature.to_s.downcase.strip
143
+ "#{set_key}-#{feature}"
134
144
  end
135
145
 
136
146
  def change_values(namespaced, group)
@@ -139,7 +149,7 @@ module Flippant
139
149
  new_values = yield(old_values)
140
150
 
141
151
  client.multi do
142
- client.hset(namespaced, group, dump(new_values))
152
+ client.hset(namespaced, group, serializer.dump(new_values))
143
153
  end
144
154
  end
145
155
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flippant
4
+ module Rules
5
+ def self.enabled_for_actor?(rules, actor, groups = Flippant.registered)
6
+ rules.any? do |name, values|
7
+ if (block = groups[name])
8
+ block.call(actor, values)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Flippant
4
- VERSION = "0.6.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/flippant.rb CHANGED
@@ -5,9 +5,11 @@ require "json"
5
5
  module Flippant
6
6
  autoload :Error, "flippant/errors"
7
7
  autoload :Registry, "flippant/registry"
8
+ autoload :Rules, "flippant/rules"
8
9
 
9
10
  module Adapter
10
11
  autoload :Memory, "flippant/adapters/memory"
12
+ autoload :Postgres, "flippant/adapters/postgres"
11
13
  autoload :Redis, "flippant/adapters/redis"
12
14
  end
13
15
 
@@ -17,15 +19,10 @@ module Flippant
17
19
  attr_writer :adapter, :registry, :serializer
18
20
 
19
21
  def_delegators :adapter,
20
- :add,
21
22
  :breakdown,
22
23
  :clear,
23
- :disable,
24
- :enabled?,
25
- :exists?,
26
24
  :features,
27
- :remove,
28
- :rename
25
+ :setup
29
26
 
30
27
  def_delegators :registry,
31
28
  :register,
@@ -35,10 +32,46 @@ module Flippant
35
32
 
36
33
  # Guarded Delegation
37
34
 
35
+ def add(feature)
36
+ adapter.add(normalize(feature))
37
+ end
38
+
39
+ def dump(path)
40
+ File.open(path, "w+") do |file|
41
+ file << serializer.dump(breakdown)
42
+ end
43
+ end
44
+
45
+ def exists?(features, group = nil)
46
+ adapter.exists?(normalize(features), group)
47
+ end
48
+
38
49
  def enable(feature, group, values = [])
39
50
  raise Flippant::Error, "Unknown group: #{group}" unless registered?(group)
40
51
 
41
- adapter.enable(feature, group, values)
52
+ adapter.enable(normalize(feature), group, values)
53
+ end
54
+
55
+ def enabled?(feature, actor)
56
+ adapter.enabled?(normalize(feature), actor)
57
+ end
58
+
59
+ def disable(feature, group, values = [])
60
+ adapter.disable(normalize(feature), group, values)
61
+ end
62
+
63
+ def load(path)
64
+ File.open(path, "r") do |file|
65
+ adapter.load(serializer.load(file.read))
66
+ end
67
+ end
68
+
69
+ def rename(old_name, new_name)
70
+ adapter.rename(normalize(old_name), normalize(new_name))
71
+ end
72
+
73
+ def remove(feature)
74
+ adapter.remove(normalize(feature))
42
75
  end
43
76
 
44
77
  # Configuration
@@ -66,4 +99,10 @@ module Flippant
66
99
  else adapter.clear && registry.clear
67
100
  end
68
101
  end
102
+
103
+ private
104
+
105
+ def normalize(feature)
106
+ feature.to_s.downcase.strip
107
+ end
69
108
  end
metadata CHANGED
@@ -1,15 +1,43 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flippant
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Parker Selbert
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2017-08-08 00:00:00.000000000 Z
11
+ date: 2018-03-20 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.1'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: benchmark-ips
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
13
41
  - !ruby/object:Gem::Dependency
14
42
  name: bundler
15
43
  requirement: !ruby/object:Gem::Requirement
@@ -24,6 +52,20 @@ dependencies:
24
52
  - - ">="
25
53
  - !ruby/object:Gem::Version
26
54
  version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: pg
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.0'
27
69
  - !ruby/object:Gem::Dependency
28
70
  name: rake
29
71
  requirement: !ruby/object:Gem::Requirement
@@ -39,47 +81,47 @@ dependencies:
39
81
  - !ruby/object:Gem::Version
40
82
  version: '0'
41
83
  - !ruby/object:Gem::Dependency
42
- name: rspec
84
+ name: redis
43
85
  requirement: !ruby/object:Gem::Requirement
44
86
  requirements:
45
87
  - - "~>"
46
88
  - !ruby/object:Gem::Version
47
- version: '3.2'
89
+ version: '4.0'
48
90
  type: :development
49
91
  prerelease: false
50
92
  version_requirements: !ruby/object:Gem::Requirement
51
93
  requirements:
52
94
  - - "~>"
53
95
  - !ruby/object:Gem::Version
54
- version: '3.2'
96
+ version: '4.0'
55
97
  - !ruby/object:Gem::Dependency
56
- name: redis
98
+ name: rspec
57
99
  requirement: !ruby/object:Gem::Requirement
58
100
  requirements:
59
101
  - - "~>"
60
102
  - !ruby/object:Gem::Version
61
- version: '3.3'
103
+ version: '3.7'
62
104
  type: :development
63
105
  prerelease: false
64
106
  version_requirements: !ruby/object:Gem::Requirement
65
107
  requirements:
66
108
  - - "~>"
67
109
  - !ruby/object:Gem::Version
68
- version: '3.3'
110
+ version: '3.7'
69
111
  - !ruby/object:Gem::Dependency
70
112
  name: rubocop
71
113
  requirement: !ruby/object:Gem::Requirement
72
114
  requirements:
73
115
  - - "~>"
74
116
  - !ruby/object:Gem::Version
75
- version: '0.47'
117
+ version: '0.52'
76
118
  type: :development
77
119
  prerelease: false
78
120
  version_requirements: !ruby/object:Gem::Requirement
79
121
  requirements:
80
122
  - - "~>"
81
123
  - !ruby/object:Gem::Version
82
- version: '0.47'
124
+ version: '0.52'
83
125
  description: Fast feature toggling for applications
84
126
  email:
85
127
  - parker@sorentwo.com
@@ -90,12 +132,14 @@ files:
90
132
  - ".gitignore"
91
133
  - ".rspec"
92
134
  - ".rubocop.yml"
135
+ - ".tool-versions"
93
136
  - ".travis.yml"
94
137
  - CHANGELOG.md
95
138
  - Gemfile
96
139
  - LICENSE.txt
97
140
  - README.md
98
141
  - Rakefile
142
+ - benchmarks/adapters.rb
99
143
  - bin/console
100
144
  - bin/rspec
101
145
  - bin/rubocop
@@ -103,6 +147,7 @@ files:
103
147
  - flippant.gemspec
104
148
  - lib/flippant.rb
105
149
  - lib/flippant/adapters/memory.rb
150
+ - lib/flippant/adapters/postgres.rb
106
151
  - lib/flippant/adapters/redis.rb
107
152
  - lib/flippant/errors.rb
108
153
  - lib/flippant/registry.rb
@@ -128,7 +173,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
128
173
  version: '0'
129
174
  requirements: []
130
175
  rubyforge_project:
131
- rubygems_version: 2.5.1
176
+ rubygems_version: 2.7.3
132
177
  signing_key:
133
178
  specification_version: 4
134
179
  summary: Fast feature toggling for applications