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
data/.gitignore ADDED
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ spec/spec.sqlite3
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
data/.travis.yml ADDED
@@ -0,0 +1,6 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.2
4
+ - 1.9.3
5
+ - 2.0.0
6
+ script: bundle exec rspec
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in detour.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Jonathan Clem
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,261 @@
1
+ # Detour
2
+
3
+ Rollouts for `ActiveRecord` models. It is a spiritual fork of [ArRollout](https://github.com/markpundsack/ar_rollout).
4
+
5
+ | development status | master status | Code Climate |
6
+ | ------------------ | ------------- | ------------ |
7
+ | [![development status][dev_image]][branch_status] | [![master status][master_image]][branch_status] | [![Code Climate][code_climate_image]][code_climate]
8
+
9
+ [dev_image]: https://api.travis-ci.org/jclem/detour.png?branch=development
10
+ [master_image]: https://api.travis-ci.org/jclem/detour.png?branch=master
11
+ [branch_status]: https://travis-ci.org/jclem/detour/branches
12
+ [code_climate_image]: https://codeclimate.com/github/jclem/detour.png
13
+ [code_climate]: https://codeclimate.com/github/jclem/detour
14
+
15
+ ## Contents
16
+
17
+ - [Installation](#installation)
18
+ - [Usage](#usage)
19
+ - [Marking a model as flaggable](#marking-a-model-as-flaggable)
20
+ - [Determining if a record is flagged into a feature](#determining-if-a-record-is-flagged-into-a-feature)
21
+ - [Feature operations](#feature-operations)
22
+ - [Creating features](#creating-features)
23
+ - [Destroying features](#destroying-features)
24
+ - [Flagging a record into a feature](#flagging-a-record-into-a-feature)
25
+ - [Removing a flag-in for a record for a feature](#removing-a-flag-in-for-a-record-for-a-feature)
26
+ - [Opt a record out of a feature](#opt-a-record-out-of-a-feature)
27
+ - [Un-opt out a record from a feature](#un-opt-out-a-record-from-a-feature)
28
+ - [Flag a programmatic group into a feature](#flag-a-programmatic-group-into-a-feature)
29
+ - [Remove a flag-in for a programmatic group for a feature](#remove-a-flag-in-for-a-programmatic-group-for-a-feature)
30
+ - [Flag a percentage of records into a feature](#flag-a-percentage-of-records-into-a-feature)
31
+ - [Remove a flag-in for a percentage of records for a feature](#remove-a-flag-in-for-a-percentage-of-records-for-a-feature)
32
+ - [Defining a default class](#defining-a-default-class)
33
+ - [Defining programmatic groups](#defining-programmatic-groups)
34
+ - [Contributing](#contributing)
35
+
36
+
37
+ ## Installation
38
+
39
+ Add this line to your application's Gemfile:
40
+
41
+ gem 'detour'
42
+
43
+ And then execute:
44
+
45
+ $ bundle
46
+
47
+ Or install it yourself as:
48
+
49
+ $ gem install detour
50
+
51
+ ## Usage
52
+
53
+ `Detour` works by determining whether or not a specific record
54
+ should have features accessible to it based on individual flags, flags for a
55
+ percentage of records, or flags for a programmable group of records.
56
+
57
+ ### Marking a model as flaggable
58
+
59
+ ```ruby
60
+ class User < ActiveRecord::Base
61
+ acts_as_flaggable
62
+ end
63
+ ```
64
+
65
+ Or, in order to user emails rather than IDs to identify records in rake tasks:
66
+
67
+ ```ruby
68
+ class User < ActiveRecord::Base
69
+ acts_as_flaggable find_by: :email
70
+ end
71
+ ```
72
+
73
+ This module adds `has_many` associations to `User` for `flaggable_flags` (where
74
+ the user has been individually flagged into a feature), `opt_out_flags` (where
75
+ the user has been opted out of a feature), and `features` (the features that
76
+ the user has been individually flagged into, regardless of opt outs).
77
+
78
+ However, these methods aren't enough to determine whether or not the user is
79
+ flagged into a specific feature. The `#has_feature?` method provided by
80
+ `Detour::Flaggable` should be used for this.
81
+
82
+ ### Determining if a record is flagged into a feature
83
+
84
+ `#has_feature?` has two methods of use. The first one, where it is passed a
85
+ block, will increment a `failure_count` on the given feature if the block
86
+ raises an exception (the exception is again raised after incrementing). This
87
+ currently does not alter the behavior of the feature, but it services a metrics
88
+ purpose:
89
+
90
+ ```ruby
91
+ current_user.has_feature? :new_user_interface do
92
+ render_new_user_interface
93
+ end
94
+ ```
95
+
96
+ When not given a block, it simply returns a boolean, and does not watch for
97
+ exceptions:
98
+
99
+ ```ruby
100
+ if current_user.has_feature? :new_user_interface
101
+ render_new_user_interface
102
+ end
103
+ ```
104
+
105
+ Want to make use of both? `#has_feature?` returns a boolean even when passed
106
+ a block:
107
+
108
+ ```ruby
109
+ if current_user.has_feature? :new_user_interface do
110
+ render_new_user_interface
111
+ end; else
112
+ render_old_user_interface
113
+ end
114
+ ```
115
+
116
+ ### Feature operations
117
+
118
+ Features and flags are intended to be controlled by a rake tasks. To create
119
+ them programmatically, consult the documentation.
120
+
121
+ #### Creating features
122
+
123
+ ```sh
124
+ $ bundle exec rake detour:create[new_ui]
125
+ ```
126
+
127
+ #### Destroying features
128
+
129
+ ```sh
130
+ $ bundle exec rake detour:destroy[new_ui]
131
+ ```
132
+
133
+ #### Flagging a record into a feature
134
+
135
+ This task requires passing the feature name, the record's class, and the
136
+ record's ID.
137
+
138
+ ```sh
139
+ $ bundle exec rake detour:activate[new_ui,User,2]
140
+ ```
141
+
142
+ #### Removing a flag-in for a record for a feature
143
+
144
+ This task requires passing the feature name, the record's class, and the
145
+ record's ID.
146
+
147
+ ```sh
148
+ $ bundle exec rake detour:deactivate[new_ui,User,2]
149
+ ```
150
+
151
+ #### Opt a record out of a feature
152
+
153
+ This will ensure that `record.has_feature?(:feature)` will always be false for
154
+ the given feature, regardless of other individual flag, percentage, or group
155
+ rollouts that would otherwise target this record.
156
+
157
+ This task requires passing the feature name, the record's class, and the
158
+ record's ID.
159
+
160
+ ```sh
161
+ $ bundle exec rake detour:opt_out[new_ui,User,2]
162
+ ```
163
+
164
+ #### Un-opt out a record from a feature
165
+
166
+ This task requires passing the feature name, the record's class, and the
167
+ record's ID.
168
+
169
+ ```sh
170
+ $ bundle exec rake detour:un_opt_out[new_ui,User,2]
171
+ ```
172
+
173
+ #### Flag a programmatic group into a feature
174
+
175
+ This task requires passing the feature name, the record class for the group,
176
+ and the name of the group.
177
+
178
+ ```sh
179
+ $ bundle exec rake detour:activate_group[new_ui,User,admins]
180
+ ```
181
+
182
+ #### Remove a flag-in for a programmatic group for a feature
183
+
184
+ This task requires passing the feature name, the record class for the group,
185
+ and the name of the group.
186
+
187
+ ```sh
188
+ $ bundle exec rake detour:deactivate_group[new_ui,User,admins]
189
+ ```
190
+
191
+ #### Flag a percentage of records into a feature
192
+
193
+ This relies on the following formula to determine if a record is flagged in to
194
+ a feature based on percentage:
195
+
196
+ ```ruby
197
+ record.id % 10 < percentage / 10
198
+ ```
199
+
200
+ This task requires passing the feature name, the record class for the group,
201
+ and the percentage of records to be flagged in.
202
+
203
+ ```sh
204
+ $ bundle exec rake detour:activate_percentage[new_ui,User,20]
205
+ ```
206
+
207
+ #### Remove a flag-in for a percentage of records for a feature
208
+
209
+ This task requires passing the feature name, and the record class for the group.
210
+
211
+ ```sh
212
+ $ bundle exec rake detour:deactivate_percentage[new_ui,User]
213
+ ```
214
+
215
+ ### Defining a default class
216
+
217
+ In order to provide passing a class name into rake tasks, a default class can
218
+ be set:
219
+
220
+ ```ruby
221
+ Detour.configure do |config|
222
+ config.default_flaggable_class_name = "User"
223
+ end
224
+ ```
225
+
226
+ Then, in your rake tasks:
227
+
228
+ ```sh
229
+ # Will activate feature "foo" for all instances of User that match the admins group.
230
+ $ bundle exec rake detour:activate_group[foo,admins]
231
+ ```
232
+
233
+ ### Defining programmatic groups
234
+
235
+ A specific group of records matching a given block can be flagged into a
236
+ feature. In order to define these groups, use
237
+ `Detour.configure`:
238
+
239
+ ```ruby
240
+ Detour.configure do |config|
241
+ # Any User that returns truthy for `user.admin?` will be included in this
242
+ # group: `admins`.
243
+ config.define_user_group :admins do |user|
244
+ user.admin?
245
+ end
246
+
247
+ # Any FizzBuzz that returns truthy for `fizz_buzz.bar?` will be included in
248
+ # this group: `is_bar`.
249
+ config.define_fizz_buzz_group :is_bar do |fizz_buzz|
250
+ fizz_buzz.bar?
251
+ end
252
+ end
253
+ ```
254
+
255
+ ## Contributing
256
+
257
+ 1. Fork it ( http://github.com/<my-github-username>/detour/fork )
258
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
259
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
260
+ 4. Push to the branch (`git push origin my-new-feature`)
261
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/detour.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "detour/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "detour"
8
+ spec.version = Detour::VERSION
9
+ spec.authors = ["Jonathan Clem"]
10
+ spec.email = ["jonathan@heroku.com"]
11
+ spec.summary = %q{Rollouts (feature flags) for ActiveRecord models.}
12
+ spec.description = %q{Rollouts (feature flags) for ActiveRecord models.}
13
+ spec.homepage = "https://github.com/jclem/detour"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.required_ruby_version = ">= 1.9.3"
22
+
23
+ spec.add_dependency "activerecord", "~> 3.2"
24
+ spec.add_dependency "rails", "~> 3.2"
25
+ spec.add_development_dependency "bundler", "~> 1.3"
26
+ spec.add_development_dependency "rake"
27
+ spec.add_development_dependency "rspec-rails"
28
+ spec.add_development_dependency "fakefs"
29
+ spec.add_development_dependency "shoulda-matchers"
30
+ spec.add_development_dependency "sqlite3-ruby"
31
+ spec.add_development_dependency "pry-debugger"
32
+ spec.add_development_dependency "yard"
33
+ end
@@ -0,0 +1,36 @@
1
+ module Detour::ActsAsFlaggable
2
+ # Sets up ActiveRecord associations for the including class, and includes
3
+ # {Detour::Flaggable} in the class.
4
+ #
5
+ # @example
6
+ # class User < ActiveRecord::Base
7
+ # acts_as_taggable find_by: :email
8
+ # end
9
+ #
10
+ # @option options [Symbol] :find_by The field to find the record by when
11
+ # running rake tasks. Defaults to :id.
12
+ def acts_as_flaggable(options = {})
13
+ class_eval do
14
+ @detour_flaggable_find_by = :id
15
+
16
+ has_many :flaggable_flags,
17
+ as: :flaggable,
18
+ class_name: "Detour::FlaggableFlag"
19
+
20
+ has_many :opt_out_flags,
21
+ as: :flaggable,
22
+ class_name: "Detour::OptOutFlag"
23
+
24
+ has_many :features,
25
+ through: :flaggable_flags,
26
+ class_name: "Detour::Feature"
27
+
28
+ if options[:find_by]
29
+ @detour_flaggable_find_by = options[:find_by]
30
+ end
31
+
32
+ extend Detour::Flaggable::ClassMethods
33
+ include Detour::Flaggable
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,312 @@
1
+ # Represents an individual feature that may be rolled out to a set of records
2
+ # via individual flags, percentages, or defined groups.
3
+ class Detour::Feature < ActiveRecord::Base
4
+ # A hash representing the groups that have been defined.
5
+ @defined_groups = {}
6
+
7
+ # Directories to grep for feature tests
8
+ @grep_dirs = []
9
+
10
+ self.table_name = :detour_features
11
+
12
+ has_many :flaggable_flags
13
+ has_many :group_flags
14
+ has_many :percentage_flags
15
+ has_many :opt_out_flags
16
+ has_many :flags, dependent: :destroy
17
+
18
+ validates :name, presence: true, uniqueness: true
19
+
20
+ attr_accessible :name
21
+
22
+ # Returns an instance variable intended to hold an array of the lines of code
23
+ # that this feature appears on.
24
+ #
25
+ # @return [Array<String>] The lines that this rollout appears on (if
26
+ # {Detour::Feature.all_with_lines} has already been called).
27
+ def lines
28
+ @lines ||= []
29
+ end
30
+
31
+ # Determines whether or not the given instance has had the feature rolled out
32
+ # to it either via direct flagging-in, percentage, or by group membership.
33
+ #
34
+ # @example
35
+ # feature.match?(current_user)
36
+ #
37
+ # @param [ActiveRecord::Base] instance A record to be tested for feature
38
+ # rollout.
39
+ #
40
+ # @return Whether or not the given instance has the feature rolled out to it.
41
+ def match?(instance)
42
+ match_id?(instance) || match_percentage?(instance) || match_groups?(instance)
43
+ end
44
+
45
+ # Determines whether or not the given instance has had the feature rolled out
46
+ # to it via direct flagging-in.
47
+ #
48
+ # @example
49
+ # feature.match_id?(current_user)
50
+ #
51
+ # @param [ActiveRecord::Base] instance A record to be tested for feature
52
+ # rollout.
53
+ #
54
+ # @return Whether or not the given instance has the feature rolled out to it
55
+ # via direct flagging-in.
56
+ def match_id?(instance)
57
+ flaggable_flags.where(flaggable_type: instance.class.to_s, flaggable_id: instance.id).any?
58
+ end
59
+
60
+ # Determines whether or not the given instance has had the feature rolled out
61
+ # to it via percentage.
62
+ #
63
+ # @example
64
+ # feature.match_percentage?(current_user)
65
+ #
66
+ # @param [ActiveRecord::Base] instance A record to be tested for feature
67
+ # rollout.
68
+ #
69
+ # @return Whether or not the given instance has the feature rolled out to it
70
+ # via direct percentage.
71
+ def match_percentage?(instance)
72
+ flag = percentage_flags.find(:first, conditions: ["flaggable_type = ?", instance.class.to_s])
73
+ percentage = flag ? flag.percentage : 0
74
+
75
+ instance.id % 10 < percentage / 10
76
+ end
77
+
78
+ # Determines whether or not the given instance has had the feature rolled out
79
+ # to it via group membership.
80
+ #
81
+ # @example
82
+ # feature.match_groups?(current_user)
83
+ #
84
+ # @param [ActiveRecord::Base] instance A record to be tested for feature
85
+ # rollout.
86
+ #
87
+ # @return Whether or not the given instance has the feature rolled out to it
88
+ # via direct group membership.
89
+ def match_groups?(instance)
90
+ klass = instance.class.to_s
91
+
92
+ return unless self.class.defined_groups[klass]
93
+
94
+ group_names = group_flags.find_all_by_flaggable_type(klass).collect(&:group_name)
95
+
96
+ self.class.defined_groups[klass].collect { |group_name, block|
97
+ block.call(instance) if group_names.include? group_name
98
+ }.any?
99
+ end
100
+
101
+ class << self
102
+ # Returns the defined groups.
103
+ def defined_groups
104
+ @defined_groups
105
+ end
106
+
107
+ # Returns the default flaggable class.
108
+ def default_flaggable_class_name
109
+ @default_flaggable_class_name
110
+ end
111
+
112
+ # Sets the default flaggable class.
113
+ def default_flaggable_class_name=(klass)
114
+ @default_flaggable_class_name = klass
115
+ end
116
+
117
+ # A list of directories to search through when finding feature checks.
118
+ def grep_dirs
119
+ @grep_dirs
120
+ end
121
+
122
+ # Set the list of directories to search through when finding feature checks.
123
+ def grep_dirs=(grep_dirs)
124
+ @grep_dirs = grep_dirs
125
+ end
126
+
127
+ # Return an array of both every feature in the database as well as every
128
+ # feature that is checked for in `@grep_dirs`. Features that are checked
129
+ # for but not persisted will be returned as unpersisted instances of this
130
+ # class. Each instance returned will have its `@lines` set to an array
131
+ # containing every line in `@grep_dirs` where it is checked for.
132
+ #
133
+ # @return [Array<Detour::Feature>] Every persisted and
134
+ # checked-for feature.
135
+ def all_with_lines
136
+ obj = all.each_with_object({}) { |feature, obj| obj[feature.name] = feature }
137
+
138
+ Dir[*@grep_dirs].each do |path|
139
+ next if File.directory? path
140
+
141
+ File.open path do |file|
142
+ file.each_line.with_index(1) do |line, i|
143
+ line.scan(/\.has_feature\?\s*\(*:(\w+)/).each do |match|
144
+ match = match[0]
145
+ obj[match] ||= find_or_initialize_by_name(match)
146
+ obj[match].lines << "#{path}#L#{i}"
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ obj.values
153
+ end
154
+
155
+ # Add a record to the given feature. If the feature is not found, an
156
+ # ActiveRecord::RecordNotFound will be raised.
157
+ #
158
+ # @example
159
+ # Detour::Feature.add_record_to_feature user, :new_ui
160
+ #
161
+ # @param [ActiveRecord::Base] record A record to add the feature to.
162
+ # @param [String,Symbol] feature_name The feature to be added to the record.
163
+ #
164
+ # @return [Detour::Flag] The
165
+ # {Detour::Flag Flag} created.
166
+ def add_record_to_feature(record, feature_name)
167
+ feature = find_by_name!(feature_name)
168
+ feature.flaggable_flags.where(flaggable_type: record.class.to_s, flaggable_id: record.id).first_or_create!
169
+ end
170
+
171
+ # Remove a record from the given feature. If the feature is not found, an
172
+ # ActiveRecord::RecordNotFound will be raised.
173
+ #
174
+ # @example
175
+ # Detour::Feature.remove_record_from_feature user, :new_ui
176
+ #
177
+ # @param [ActiveRecord::Base] record A record to remove the feature from.
178
+ # @param [String,Symbol] feature_name The feature to be removed from the
179
+ # record.
180
+ def remove_record_from_feature(record, feature_name)
181
+ feature = find_by_name!(feature_name)
182
+ feature.flaggable_flags.where(flaggable_type: record.class.to_s, flaggable_id: record.id).destroy_all
183
+ end
184
+
185
+ # Opt the given record out of a feature. If the feature is not found, an
186
+ # ActiveRecord::RecordNotFound will be raised. An opt out ensures that no
187
+ # matter what, `record.rollout?(:rollout)` will always return false for any
188
+ # opted-out-of features.
189
+ #
190
+ # @param [ActiveRecord::Base] record A record to opt out of the feature.
191
+ # @param [String,Symbol] feature_name The feature to be opted out of.
192
+ #
193
+ # @example
194
+ # Detour::Feature.opt_record_out_of_feature user, :new_ui
195
+ #
196
+ # @return [Detour::OptOut] The
197
+ # {Detour::OptOut OptOut} created.
198
+ def opt_record_out_of_feature(record, feature_name)
199
+ feature = find_by_name!(feature_name)
200
+ feature.opt_out_flags.where(flaggable_type: record.class.to_s, flaggable_id: record.id).first_or_create!
201
+ end
202
+
203
+ # Remove any opt out for the given record out of a feature. If the feature
204
+ # is not found, an ActiveRecord::RecordNotFound will be raised.
205
+ #
206
+ # @example
207
+ # Detour::Feature.un_opt_record_out_of_feature user, :new_ui
208
+ #
209
+ # @param [ActiveRecord::Base] record A record to un-opt-out of the feature.
210
+ # @param [String,Symbol] feature_name The feature to be un-opted-out of.
211
+ def un_opt_record_out_of_feature(record, feature_name)
212
+ feature = find_by_name!(feature_name)
213
+ feature.opt_out_flags.where(flaggable_type: record.class.to_s, flaggable_id: record.id).destroy_all
214
+ end
215
+
216
+ # Add a group to the given feature. If the feature is not found, an
217
+ # ActiveRecord::RecordNotFound will be raised.
218
+ #
219
+ # @example
220
+ # Detour::Feature.add_group_to_feature "User", "admin", :delete_records
221
+ #
222
+ # @param [String] flaggable_type The class (as a string) that the group
223
+ # should be associated with.
224
+ # @param [String] group_name The name of the group to have the feature
225
+ # added to it.
226
+ # @param [String,Symbol] feature_name The feature to be added to the group.
227
+ #
228
+ # @return [Detour::Flag] The
229
+ # {Detour::Flag Flag} created.
230
+ def add_group_to_feature(flaggable_type, group_name, feature_name)
231
+ feature = find_by_name!(feature_name)
232
+ feature.group_flags.where(flaggable_type: flaggable_type, group_name: group_name).first_or_create!
233
+ end
234
+
235
+ # Remove a group from agiven feature. If the feature is not found, an
236
+ # ActiveRecord::RecordNotFound will be raised.
237
+ #
238
+ # @example
239
+ # Detour::Feature.remove_group_from_feature "User", "admin", :delete_records
240
+ #
241
+ # @param [String] flaggable_type The class (as a string) that the group should
242
+ # be removed from.
243
+ # @param [String] group_name The name of the group to have the feature
244
+ # removed from it.
245
+ # @param [String,Symbol] feature_name The feature to be removed from the
246
+ # group.
247
+ def remove_group_from_feature(flaggable_type, group_name, feature_name)
248
+ feature = find_by_name!(feature_name)
249
+ feature.group_flags.where(flaggable_type: flaggable_type, group_name: group_name).destroy_all
250
+ end
251
+
252
+ # Add a percentage of records to the given feature. If the feature is not
253
+ # found, an ActiveRecord::RecordNotFound will be raised.
254
+ #
255
+ # @example
256
+ # Detour::Feature.add_percentage_to_feature "User", 75, :delete_records
257
+ #
258
+ # @param [String] flaggable_type The class (as a string) that the percetnage
259
+ # should be associated with.
260
+ # @param [Integer] percentage The percentage of `flaggable_type` records
261
+ # that the feature will be available for.
262
+ # @param [String,Symbol] feature_name The feature to be added to the
263
+ # percentage of records.
264
+ #
265
+ # @return [Detour::Flag] The
266
+ # {Detour::Flag Flag} created.
267
+ def add_percentage_to_feature(flaggable_type, percentage, feature_name)
268
+ feature = find_by_name!(feature_name)
269
+
270
+ flag = feature.percentage_flags.where(flaggable_type: flaggable_type).first_or_initialize
271
+ flag.update_attributes!(percentage: percentage)
272
+ end
273
+
274
+ # Remove any percentage flags for the given feature. If the feature is not
275
+ # found, an ActiveRecord::RecordNotFound will be raised.
276
+ #
277
+ # @example
278
+ # Detour::Feature.remove_percentage_from_feature "User", delete_records
279
+ #
280
+ # @param [String] flaggable_type The class (as a string) that the percetnage
281
+ # should be removed from.
282
+ # @param [String,Symbol] feature_name The feature to have the percentage
283
+ # flag removed from.
284
+ def remove_percentage_from_feature(flaggable_type, feature_name)
285
+ feature = find_by_name!(feature_name)
286
+ feature.percentage_flags.where(flaggable_type: flaggable_type).destroy_all
287
+ end
288
+
289
+ # Allows for methods of the form `define_user_group` that call the private
290
+ # method `define_group_for_class`. A new group for any `User` records will
291
+ # be created that rollouts can be attached to.
292
+ #
293
+ # @example
294
+ # Detour::Feature.define_user_group :admins do |user|
295
+ # user.admin?
296
+ # end
297
+ def method_missing(method, *args, &block)
298
+ if /^define_(?<klass>[a-z0-9_]+)_group/ =~ method
299
+ define_group_for_class(klass.classify, args[0], &block)
300
+ else
301
+ super
302
+ end
303
+ end
304
+
305
+ private
306
+
307
+ def define_group_for_class(klass, group_name, &block)
308
+ @defined_groups[klass] ||= {}
309
+ @defined_groups[klass][group_name] = block
310
+ end
311
+ end
312
+ end