detour 0.0.1
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.
- data/.gitignore +18 -0
- data/.travis.yml +6 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +261 -0
- data/Rakefile +1 -0
- data/detour.gemspec +33 -0
- data/lib/detour/acts_as_flaggable.rb +36 -0
- data/lib/detour/feature.rb +312 -0
- data/lib/detour/flag.rb +13 -0
- data/lib/detour/flaggable.rb +66 -0
- data/lib/detour/flaggable_flag.rb +10 -0
- data/lib/detour/group_flag.rb +8 -0
- data/lib/detour/opt_out_flag.rb +10 -0
- data/lib/detour/percentage_flag.rb +11 -0
- data/lib/detour/version.rb +3 -0
- data/lib/detour.rb +35 -0
- data/lib/generators/active_record_rollout_generator.rb +20 -0
- data/lib/generators/templates/active_record_rollout.rb +5 -0
- data/lib/generators/templates/migration.rb +30 -0
- data/lib/tasks/detour.rake +119 -0
- data/spec/integration/flag_rollout_spec.rb +27 -0
- data/spec/integration/group_rollout_spec.rb +20 -0
- data/spec/integration/percentage_rollout_spec.rb +13 -0
- data/spec/lib/active_record/rollout/acts_as_flaggable_spec.rb +31 -0
- data/spec/lib/active_record/rollout/feature_spec.rb +280 -0
- data/spec/lib/active_record/rollout/flag_spec.rb +8 -0
- data/spec/lib/active_record/rollout/flaggable_flag_spec.rb +9 -0
- data/spec/lib/active_record/rollout/flaggable_spec.rb +149 -0
- data/spec/lib/active_record/rollout/group_flag_spec.rb +8 -0
- data/spec/lib/active_record/rollout/opt_out_flag_spec.rb +9 -0
- data/spec/lib/active_record/rollout/percentage_flag_spec.rb +10 -0
- data/spec/lib/tasks/detour_rake_spec.rb +162 -0
- data/spec/spec_helper.rb +40 -0
- data/spec/support/schema.rb +13 -0
- data/spec/support/shared_contexts/rake.rb +20 -0
- metadata +258 -0
data/lib/detour/flag.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# Indicates that a specific feature has been rolled out to an individual
|
2
|
+
# Table for storing flaggable flag-ins, group flag-ins, or percentage-based
|
3
|
+
# flag-ins.
|
4
|
+
class Detour::Flag < ActiveRecord::Base
|
5
|
+
self.table_name = :detour_flags
|
6
|
+
|
7
|
+
belongs_to :feature
|
8
|
+
|
9
|
+
validates :feature_id, presence: true
|
10
|
+
validates :flaggable_type, presence: true
|
11
|
+
|
12
|
+
attr_accessible :flaggable_type
|
13
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Detour::Flaggable
|
2
|
+
module ClassMethods
|
3
|
+
# Finds a record by the field set by the :find_by param in
|
4
|
+
# `acts_as_flaggable`. If no :find_by param was provided, :id is used.
|
5
|
+
#
|
6
|
+
# @param [String,Integer] value The value to find the record by.
|
7
|
+
def flaggable_find!(value)
|
8
|
+
send("find_by_#{@detour_flaggable_find_by}!", value)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
# Returns whether or not the object has access to the given feature. If given
|
13
|
+
# a block, it will call the block if the user has access to the feature.
|
14
|
+
#
|
15
|
+
# If an exception is raised in the block, it will increment the
|
16
|
+
# `failure_count` of the feature and raise the exception.
|
17
|
+
#
|
18
|
+
# @example
|
19
|
+
# # Exceptions will be tracked in the `failure_count` of :new_user_interface.
|
20
|
+
# user.has_feature?(:new_user_interface) do
|
21
|
+
# # ...
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
# @example
|
25
|
+
# # Exceptions will *not* be tracked in the `failure_count` of :new_user_interface.
|
26
|
+
# if user.has_feature?(:new_user_interface)
|
27
|
+
# # ...
|
28
|
+
# end
|
29
|
+
#
|
30
|
+
# @param [Symbol] feature_name The name of the
|
31
|
+
# {Detour::Feature Feature} being checked.
|
32
|
+
# @param [Proc] &block A block to be called if the user is flagged in to the
|
33
|
+
# feature.
|
34
|
+
def has_feature?(feature_name, &block)
|
35
|
+
if detour_features.include? feature_name.to_s
|
36
|
+
match = true
|
37
|
+
else
|
38
|
+
feature = Detour::Feature.find_by_name(feature_name)
|
39
|
+
return false unless feature
|
40
|
+
|
41
|
+
opt_out = opt_out_flags.find_by_feature_id(feature.id)
|
42
|
+
return false if opt_out
|
43
|
+
|
44
|
+
match = feature.match? self
|
45
|
+
|
46
|
+
if match
|
47
|
+
detour_features << feature.name.to_s
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
if match && block_given?
|
52
|
+
begin
|
53
|
+
yield
|
54
|
+
rescue => e
|
55
|
+
feature.increment! :failure_count
|
56
|
+
raise e
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
match
|
61
|
+
end
|
62
|
+
|
63
|
+
def detour_features
|
64
|
+
@detour_features ||= []
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# An individual record of a certain type may be flagged into a feature with
|
2
|
+
# this class.
|
3
|
+
class Detour::FlaggableFlag < Detour::Flag
|
4
|
+
belongs_to :flaggable, polymorphic: true
|
5
|
+
|
6
|
+
validates :flaggable_id, presence: true
|
7
|
+
validates :feature_id, uniqueness: { scope: [:flaggable_type, :flaggable_id] }
|
8
|
+
|
9
|
+
attr_accessible :flaggable
|
10
|
+
end
|
@@ -0,0 +1,8 @@
|
|
1
|
+
# A group of flaggable records of a given class may be flagged into a feature
|
2
|
+
# with this class.
|
3
|
+
class Detour::GroupFlag < Detour::Flag
|
4
|
+
validates :group_name, presence: true
|
5
|
+
|
6
|
+
attr_accessible :group_name
|
7
|
+
validates :feature_id, uniqueness: { scope: [:flaggable_type, :group_name] }
|
8
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# Ensures that a feature will never be available to the associated record,
|
2
|
+
# even in the case of, for example, a 100% flag.
|
3
|
+
class Detour::OptOutFlag < Detour::Flag
|
4
|
+
belongs_to :flaggable, polymorphic: true
|
5
|
+
|
6
|
+
validates :flaggable_id, presence: true
|
7
|
+
validates :feature_id, uniqueness: { scope: [:flaggable_type, :flaggable_id] }
|
8
|
+
|
9
|
+
attr_accessible :flaggable
|
10
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# A percentage of flaggable records of a given class may be flagged into a feature
|
2
|
+
# with this class.
|
3
|
+
class Detour::PercentageFlag < Detour::Flag
|
4
|
+
validates :percentage,
|
5
|
+
presence: true,
|
6
|
+
numericality: { greater_than: 0, less_than_or_equal_to: 100 }
|
7
|
+
|
8
|
+
validates :feature_id, uniqueness: { scope: :flaggable_type }
|
9
|
+
|
10
|
+
attr_accessible :percentage
|
11
|
+
end
|
data/lib/detour.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
require "active_record"
|
2
|
+
require "detour/version"
|
3
|
+
require "detour/feature"
|
4
|
+
require "detour/flag"
|
5
|
+
require "detour/flaggable_flag"
|
6
|
+
require "detour/group_flag"
|
7
|
+
require "detour/percentage_flag"
|
8
|
+
require "detour/opt_out_flag"
|
9
|
+
require "detour/flaggable"
|
10
|
+
require "detour/acts_as_flaggable"
|
11
|
+
|
12
|
+
module Detour
|
13
|
+
# Allows for configuration of Detour::Feature, mostly intended
|
14
|
+
# for defining groups:
|
15
|
+
#
|
16
|
+
# @example
|
17
|
+
# Detour.configure do |config|
|
18
|
+
# config.define_user_group :admins do |user|
|
19
|
+
# user.admin?
|
20
|
+
# end
|
21
|
+
# end
|
22
|
+
def self.configure(&block)
|
23
|
+
yield Detour::Feature
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class Detour::Task < Rails::Railtie
|
28
|
+
rake_tasks do
|
29
|
+
Dir[File.join(File.dirname(__FILE__), '../tasks/*.rake')].each { |f| load f }
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
if defined?(ActiveRecord::Base)
|
34
|
+
ActiveRecord::Base.extend Detour::ActsAsFlaggable
|
35
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require "detour"
|
2
|
+
require "rails/generators"
|
3
|
+
require "rails/generators/active_record"
|
4
|
+
|
5
|
+
class DetourGenerator < Rails::Generators::Base
|
6
|
+
include Rails::Generators::Migration
|
7
|
+
extend ActiveRecord::Generators::Migration
|
8
|
+
|
9
|
+
source_root File.expand_path("../templates", __FILE__)
|
10
|
+
|
11
|
+
desc "Creates migration for Detour"
|
12
|
+
def create_migration_file
|
13
|
+
migration_template "migration.rb", "db/migrate/setup_detour.rb"
|
14
|
+
end
|
15
|
+
|
16
|
+
desc "Sets up an initializer for Detour"
|
17
|
+
def create_initializer
|
18
|
+
copy_file "detour.rb", "config/initializers/detour.rb"
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
class SetupDetour < ActiveRecord::Migration
|
2
|
+
def change
|
3
|
+
create_table :detour_features do |t|
|
4
|
+
t.string :name
|
5
|
+
t.integer :failure_count, default: 0
|
6
|
+
t.timestamps
|
7
|
+
end
|
8
|
+
|
9
|
+
add_index :detour_features, :name, unique: true
|
10
|
+
|
11
|
+
create_table :detour_flags do |t|
|
12
|
+
t.string :type
|
13
|
+
t.integer :feature_id
|
14
|
+
t.integer :flaggable_id
|
15
|
+
t.string :flaggable_type
|
16
|
+
t.string :group_name
|
17
|
+
t.integer :percentage
|
18
|
+
t.timestamps
|
19
|
+
end
|
20
|
+
|
21
|
+
add_index :detour_flags, :type
|
22
|
+
add_index :detour_flags, :feature_id
|
23
|
+
add_index :detour_flags,
|
24
|
+
[:type, :feature_id, :flaggable_type, :flaggable_id],
|
25
|
+
name: "flag_type_feature_flaggable_type_id"
|
26
|
+
add_index :detour_flags,
|
27
|
+
[:type, :feature_id, :flaggable_type],
|
28
|
+
name: "flag_type_feature_flaggable_type"
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
namespace :detour do
|
2
|
+
desc "Create a feature"
|
3
|
+
task :create, [:feature] => :environment do |task, args|
|
4
|
+
Detour::Feature.find_or_create_by_name! args[:feature]
|
5
|
+
end
|
6
|
+
|
7
|
+
desc "Destroy a feature"
|
8
|
+
task :destroy, [:feature] => :environment do |task, args|
|
9
|
+
feature = Detour::Feature.find_by_name! args[:feature]
|
10
|
+
feature.destroy
|
11
|
+
end
|
12
|
+
|
13
|
+
desc "Activate a feature for a record"
|
14
|
+
task :activate, [:feature, :flaggable_type, :flaggable_id] => :environment do |task, args|
|
15
|
+
if args.to_a.length < 3 && Detour::Feature.default_flaggable_class_name
|
16
|
+
klass = Detour::Feature.default_flaggable_class_name.constantize
|
17
|
+
record_locator = args[:flaggable_type]
|
18
|
+
else
|
19
|
+
klass = args[:flaggable_type].constantize
|
20
|
+
record_locator = args[:flaggable_id]
|
21
|
+
end
|
22
|
+
|
23
|
+
record = klass.flaggable_find! record_locator
|
24
|
+
|
25
|
+
Detour::Feature.add_record_to_feature record, args[:feature]
|
26
|
+
end
|
27
|
+
|
28
|
+
desc "Deactivate a feature for a record"
|
29
|
+
task :deactivate, [:feature, :flaggable_type, :flaggable_id] => :environment do |task, args|
|
30
|
+
if args.to_a.length < 3 && Detour::Feature.default_flaggable_class_name
|
31
|
+
klass = Detour::Feature.default_flaggable_class_name.constantize
|
32
|
+
record_locator = args[:flaggable_type]
|
33
|
+
else
|
34
|
+
klass = args[:flaggable_type].constantize
|
35
|
+
record_locator = args[:flaggable_id]
|
36
|
+
end
|
37
|
+
|
38
|
+
record = klass.flaggable_find! record_locator
|
39
|
+
Detour::Feature.remove_record_from_feature record, args[:feature]
|
40
|
+
end
|
41
|
+
|
42
|
+
desc "Opt a record out of a feature"
|
43
|
+
task :opt_out, [:feature, :flaggable_type, :flaggable_id] => :environment do |task, args|
|
44
|
+
if args.to_a.length < 3 && Detour::Feature.default_flaggable_class_name
|
45
|
+
klass = Detour::Feature.default_flaggable_class_name.constantize
|
46
|
+
record_locator = args[:flaggable_type]
|
47
|
+
else
|
48
|
+
klass = args[:flaggable_type].constantize
|
49
|
+
record_locator = args[:flaggable_id]
|
50
|
+
end
|
51
|
+
|
52
|
+
record = klass.flaggable_find! record_locator
|
53
|
+
Detour::Feature.opt_record_out_of_feature record, args[:feature]
|
54
|
+
end
|
55
|
+
|
56
|
+
desc "Remove an opt out of a record from a feature"
|
57
|
+
task :un_opt_out, [:feature, :flaggable_type, :flaggable_id] => :environment do |task, args|
|
58
|
+
if args.to_a.length < 3 && Detour::Feature.default_flaggable_class_name
|
59
|
+
klass = Detour::Feature.default_flaggable_class_name.constantize
|
60
|
+
record_locator = args[:flaggable_type]
|
61
|
+
else
|
62
|
+
klass = args[:flaggable_type].constantize
|
63
|
+
record_locator = args[:flaggable_id]
|
64
|
+
end
|
65
|
+
|
66
|
+
record = klass.flaggable_find! record_locator
|
67
|
+
Detour::Feature.un_opt_record_out_of_feature record, args[:feature]
|
68
|
+
end
|
69
|
+
|
70
|
+
desc "Activate a feature for a group"
|
71
|
+
task :activate_group, [:feature, :flaggable_type, :group_name] => :environment do |task, args|
|
72
|
+
if args.to_a.length < 3 && Detour::Feature.default_flaggable_class_name
|
73
|
+
klass = Detour::Feature.default_flaggable_class_name
|
74
|
+
group_name = args[:flaggable_type]
|
75
|
+
else
|
76
|
+
klass = args[:flaggable_type]
|
77
|
+
group_name = args[:group_name]
|
78
|
+
end
|
79
|
+
|
80
|
+
Detour::Feature.add_group_to_feature klass, group_name, args[:feature]
|
81
|
+
end
|
82
|
+
|
83
|
+
desc "Deactivate a feature for a group"
|
84
|
+
task :deactivate_group, [:feature, :flaggable_type, :group_name] => :environment do |task, args|
|
85
|
+
if args.to_a.length < 3 && Detour::Feature.default_flaggable_class_name
|
86
|
+
klass = Detour::Feature.default_flaggable_class_name
|
87
|
+
group_name = args[:flaggable_type]
|
88
|
+
else
|
89
|
+
klass = args[:flaggable_type]
|
90
|
+
group_name = args[:group_name]
|
91
|
+
end
|
92
|
+
|
93
|
+
Detour::Feature.remove_group_from_feature klass, group_name, args[:feature]
|
94
|
+
end
|
95
|
+
|
96
|
+
desc "Activate a feature for a percentage"
|
97
|
+
task :activate_percentage, [:feature, :flaggable_type, :percentage] => :environment do |task, args|
|
98
|
+
if args.to_a.length < 3 && Detour::Feature.default_flaggable_class_name
|
99
|
+
klass = Detour::Feature.default_flaggable_class_name
|
100
|
+
percentage = args[:flaggable_type]
|
101
|
+
else
|
102
|
+
klass = args[:flaggable_type]
|
103
|
+
percentage = args[:percentage]
|
104
|
+
end
|
105
|
+
|
106
|
+
Detour::Feature.add_percentage_to_feature klass, percentage.to_i, args[:feature]
|
107
|
+
end
|
108
|
+
|
109
|
+
desc "Deactivate a feature for a percentage"
|
110
|
+
task :deactivate_percentage, [:feature, :flaggable_type] => :environment do |task, args|
|
111
|
+
if args.to_a.length < 3 && Detour::Feature.default_flaggable_class_name
|
112
|
+
klass = Detour::Feature.default_flaggable_class_name
|
113
|
+
else
|
114
|
+
klass = args[:flaggable_type]
|
115
|
+
end
|
116
|
+
|
117
|
+
Detour::Feature.remove_percentage_from_feature klass, args[:feature]
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe "flag rollouts" do
|
4
|
+
let(:user) { User.create }
|
5
|
+
let(:feature) { Detour::Feature.create(name: "foo") }
|
6
|
+
|
7
|
+
describe "creating a flag rollout" do
|
8
|
+
before do
|
9
|
+
Detour::Feature.add_record_to_feature user, feature.name
|
10
|
+
end
|
11
|
+
|
12
|
+
it "sets the feature on the user" do
|
13
|
+
feature.match_id?(user).should be_true
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
describe "removing a flag rollout" do
|
18
|
+
before do
|
19
|
+
Detour::Feature.add_record_to_feature user, feature.name
|
20
|
+
Detour::Feature.remove_record_from_feature user, feature.name
|
21
|
+
end
|
22
|
+
|
23
|
+
it "removes the feature from the user" do
|
24
|
+
feature.match_id?(user).should be_false
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe "group rollouts" do
|
4
|
+
let(:user) { User.create(name: "foo") }
|
5
|
+
let(:feature) { Detour::Feature.create(name: "foo") }
|
6
|
+
let!(:flag) { feature.group_flags.create(flaggable_type: "User", group_name: "foo_users") }
|
7
|
+
|
8
|
+
describe "creating a group rollout" do
|
9
|
+
before do
|
10
|
+
Detour::Feature.define_user_group "foo_users" do |user|
|
11
|
+
user.name == "foo"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
it "sets the feature on the user" do
|
16
|
+
feature.match_groups?(user).should be_true
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe "percentage rollouts" do
|
4
|
+
let(:users) { 10.times.collect { User.create } }
|
5
|
+
let(:feature) { Detour::Feature.create(name: "foo") }
|
6
|
+
let!(:flag) { feature.percentage_flags.create(flaggable_type: "User", percentage: 20) }
|
7
|
+
|
8
|
+
describe "creating a percentage rollout" do
|
9
|
+
it "makes the feature available to the given percentage of instances" do
|
10
|
+
users.select { |user| feature.match_percentage?(user) }.length.should eq users.length / 5
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Detour::ActsAsFlaggable do
|
4
|
+
subject { User.new }
|
5
|
+
|
6
|
+
it { should have_many :flaggable_flags }
|
7
|
+
it { should have_many :opt_out_flags }
|
8
|
+
it { should have_many(:features).through(:flaggable_flags) }
|
9
|
+
|
10
|
+
it "includes Detour::Flaggable" do
|
11
|
+
subject.class.ancestors.should include Detour::Flaggable
|
12
|
+
end
|
13
|
+
|
14
|
+
describe "#acts_as_flaggable" do
|
15
|
+
context "when given a :find_by parameter" do
|
16
|
+
class Foo < ActiveRecord::Base
|
17
|
+
acts_as_flaggable find_by: :email
|
18
|
+
end
|
19
|
+
|
20
|
+
it "sets the appropriate class variable on the class" do
|
21
|
+
Foo.instance_variable_get("@detour_flaggable_find_by").should eq :email
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
context "when not given a :find_by parameter" do
|
26
|
+
it "uses the default :id value for flaggable_find_by" do
|
27
|
+
User.instance_variable_get("@detour_flaggable_find_by").should eq :id
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|