flippant 0.6.0 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
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