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.
Files changed (37) hide show
  1. data/.gitignore +18 -0
  2. data/.travis.yml +6 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +261 -0
  6. data/Rakefile +1 -0
  7. data/detour.gemspec +33 -0
  8. data/lib/detour/acts_as_flaggable.rb +36 -0
  9. data/lib/detour/feature.rb +312 -0
  10. data/lib/detour/flag.rb +13 -0
  11. data/lib/detour/flaggable.rb +66 -0
  12. data/lib/detour/flaggable_flag.rb +10 -0
  13. data/lib/detour/group_flag.rb +8 -0
  14. data/lib/detour/opt_out_flag.rb +10 -0
  15. data/lib/detour/percentage_flag.rb +11 -0
  16. data/lib/detour/version.rb +3 -0
  17. data/lib/detour.rb +35 -0
  18. data/lib/generators/active_record_rollout_generator.rb +20 -0
  19. data/lib/generators/templates/active_record_rollout.rb +5 -0
  20. data/lib/generators/templates/migration.rb +30 -0
  21. data/lib/tasks/detour.rake +119 -0
  22. data/spec/integration/flag_rollout_spec.rb +27 -0
  23. data/spec/integration/group_rollout_spec.rb +20 -0
  24. data/spec/integration/percentage_rollout_spec.rb +13 -0
  25. data/spec/lib/active_record/rollout/acts_as_flaggable_spec.rb +31 -0
  26. data/spec/lib/active_record/rollout/feature_spec.rb +280 -0
  27. data/spec/lib/active_record/rollout/flag_spec.rb +8 -0
  28. data/spec/lib/active_record/rollout/flaggable_flag_spec.rb +9 -0
  29. data/spec/lib/active_record/rollout/flaggable_spec.rb +149 -0
  30. data/spec/lib/active_record/rollout/group_flag_spec.rb +8 -0
  31. data/spec/lib/active_record/rollout/opt_out_flag_spec.rb +9 -0
  32. data/spec/lib/active_record/rollout/percentage_flag_spec.rb +10 -0
  33. data/spec/lib/tasks/detour_rake_spec.rb +162 -0
  34. data/spec/spec_helper.rb +40 -0
  35. data/spec/support/schema.rb +13 -0
  36. data/spec/support/shared_contexts/rake.rb +20 -0
  37. metadata +258 -0
@@ -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
@@ -0,0 +1,3 @@
1
+ module Detour
2
+ VERSION = "0.0.1"
3
+ 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,5 @@
1
+ Detour.configure do |config|
2
+ # config.define_user_group :admins do |user|
3
+ # user.admin?
4
+ # end
5
+ 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