featuring 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "delegatable"
4
+ require_relative "flaggable"
5
+
6
+ module Featuring
7
+ # [public] Adds the ability to declare feature flags on a module or class.
8
+ #
9
+ # module Features
10
+ # extend Featuring::Declarable
11
+ #
12
+ # feature :some_feature
13
+ # end
14
+ #
15
+ # class User < ActiveRecord::Base
16
+ # extend Featuring::Declarable
17
+ #
18
+ # feature :some_feature
19
+ # end
20
+ #
21
+ # Each feature flag has a corresponding method to check its value:
22
+ #
23
+ # module Features
24
+ # extend Featuring::Declarable
25
+ #
26
+ # feature :some_feature
27
+ # end
28
+ #
29
+ # Features.some_feature?
30
+ # => false
31
+ #
32
+ # When using feature flags on an object, checks are available through the `features` instance method:
33
+ #
34
+ # class ObjectWithFeatures
35
+ # extend Featuring::Declarable
36
+ #
37
+ # feature :some_feature
38
+ # end
39
+ #
40
+ # instance = ObjectWithFeatures.new
41
+ # instance.features.some_feature?
42
+ # => false
43
+ #
44
+ # When using feature flag blocks, values can be passed through the check method:
45
+ #
46
+ # module Features
47
+ # extend Featuring::Declarable
48
+ #
49
+ # feature :some_feature do |value|
50
+ # value == :some_value
51
+ # end
52
+ # end
53
+ #
54
+ # Features.some_feature?(:some_value)
55
+ # => true
56
+ #
57
+ # Features.some_feature?(:some_other_value)
58
+ # => false
59
+ #
60
+ # Check methods are guaranteed to only return `true` or `false`:
61
+ #
62
+ # module Features
63
+ # extend Featuring::Declarable
64
+ #
65
+ # feature :some_feature do
66
+ # :foo
67
+ # end
68
+ # end
69
+ #
70
+ # Features.some_feature?
71
+ # => true
72
+ #
73
+ # Check methods have access to their context:
74
+ #
75
+ # class ObjectWithFeatures
76
+ # extend Featuring::Declarable
77
+ #
78
+ # feature :some_feature do
79
+ # enabled?
80
+ # end
81
+ #
82
+ # def enabled?
83
+ # true
84
+ # end
85
+ # end
86
+ #
87
+ # instance = ObjectWithFeatures.new
88
+ # instance.features.some_feature?
89
+ # => true
90
+ #
91
+ # Note that this happens through delegators, which means that instance variables are not accessible
92
+ # to the feature flag. For cases like this, define an `attr_accessor`.
93
+ #
94
+ # Feature flags can be defined in various modules and composed together:
95
+ #
96
+ # module Features
97
+ # extend Featuring::Declarable
98
+ # feature :some_feature, true
99
+ # end
100
+ #
101
+ # module AllTheFeatures
102
+ # extend Features
103
+ #
104
+ # extend Featuring::Declarable
105
+ # feature :another_feature, true
106
+ # end
107
+ #
108
+ # class User < ActiveRecord::Base
109
+ # include AllTheFeatures
110
+ # end
111
+ #
112
+ # instance = ObjectWithFeatures.new
113
+ #
114
+ # instance.some_feature?
115
+ # => true
116
+ #
117
+ # instance.another_feature?
118
+ # => true
119
+ #
120
+ # Super is fully supported! Here's an example of how it can be useful:
121
+ #
122
+ # module Features
123
+ # extend Featuring::Declarable
124
+ #
125
+ # feature :some_feature do
126
+ # [true, false].sample
127
+ # end
128
+ # end
129
+ #
130
+ # class User < ActiveRecord::Base
131
+ # include Features
132
+ #
133
+ # extend Featuring::Declarable
134
+ # feature :some_feature do
135
+ # persisted?(:some_feature) || super()
136
+ # end
137
+ # end
138
+ #
139
+ # User.find(1).features.some_feature?
140
+ # => true/false at random
141
+ #
142
+ # User.find(1).features.enable :some_feature
143
+ #
144
+ # User.find(1).features.some_feature?
145
+ # => true (always)
146
+ #
147
+ module Declarable
148
+ # [public] Define a named feature with a default value, or a block that returns the default value.
149
+ #
150
+ # By default, a feature flag is disabled. It can be enabled by specifying a value:
151
+ #
152
+ # module Features
153
+ # extend Featuring::Declarable
154
+ #
155
+ # feature :some_feature, true
156
+ # end
157
+ #
158
+ # Feature flags can also compute a value using a block:
159
+ #
160
+ # module Features
161
+ # extend Featuring::Declarable
162
+ #
163
+ # feature :some_feature do
164
+ # # perform some complex logic
165
+ # end
166
+ # end
167
+ #
168
+ # The truthiness of the block's return value determines if the feature is enabled or disabled.
169
+ #
170
+ def feature(name, default = false, &block)
171
+ define_feature_flag(name, default, &block)
172
+ end
173
+
174
+ # Called when an object is extended by `Featuring::Declarable`.
175
+ #
176
+ def self.extended(object)
177
+ super
178
+
179
+ Flaggable.setup_flaggable_object(object)
180
+ end
181
+
182
+ # Called when an object is extended by a module that is extended by `Featuring::Declarable`.
183
+ #
184
+ def extended(object)
185
+ super
186
+
187
+ case object
188
+ when Class
189
+ raise "extending classes with feature flags is not currently supported"
190
+ when Module
191
+ Flaggable.setup_flaggable_object(object)
192
+
193
+ # Add the feature check methods to the module that was extended.
194
+ #
195
+ object.internal_feature_checks_module.include internal_feature_checks_module
196
+
197
+ # Because we added feature check methods above, extend again to make them available.
198
+ #
199
+ object.extend internal_feature_checks_module
200
+
201
+ # Add the feature methods to the module's internal feature module.
202
+ #
203
+ object.internal_feature_module.include internal_feature_module
204
+
205
+ # Add our feature flags to the object's feature flags.
206
+ #
207
+ object.feature_flags.concat(feature_flags)
208
+ end
209
+ end
210
+
211
+ # Called when a module extended by `Featuring::Declarable` is included into an object.
212
+ #
213
+ def included(object)
214
+ super
215
+
216
+ Flaggable.setup_flaggable_object(object)
217
+
218
+ # Add the feature check methods to the object's internal feature class.
219
+ #
220
+ object.instance_feature_class.internal_feature_checks_module.include internal_feature_checks_module
221
+
222
+ # Because we added feature check methods above, include again to make them available.
223
+ #
224
+ object.instance_feature_class.include object.instance_feature_class.internal_feature_checks_module
225
+
226
+ # Add the feature methods to the object's internal feature class.
227
+ #
228
+ object.instance_feature_class.internal_feature_module.include internal_feature_module
229
+
230
+ # Add our feature flags to the object's feature flags.
231
+ #
232
+ object.instance_feature_class.feature_flags.concat(feature_flags)
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Featuring
6
+ # Internal concerns related to delegating feature flag checks to parent context, like this:
7
+ #
8
+ # module Features
9
+ # extend Featuring::Declarable
10
+ #
11
+ # feature :some_enabled_feature do |value|
12
+ # value == internal_value
13
+ # end
14
+ #
15
+ # def internal_value
16
+ # true
17
+ # end
18
+ #
19
+ # module_function :internal_value
20
+ # end
21
+ #
22
+ module Delegatable
23
+ def self.extended(object)
24
+ super
25
+
26
+ object.extend ClassMethods
27
+ end
28
+
29
+ def self.included(object)
30
+ super
31
+
32
+ object.extend ClassMethods
33
+ object.include InstanceMethods
34
+ end
35
+
36
+ private def internal_feature_delegates_to
37
+ self
38
+ end
39
+
40
+ private def internal_feature_delegator
41
+ @_internal_feature_delegator ||= internal_feature_delegator_class.new(
42
+ internal_feature_delegates_to
43
+ )
44
+ end
45
+
46
+ def fetch_feature_flag_value(name, *args)
47
+ !!internal_feature_delegator.public_send(name, *args)
48
+ end
49
+
50
+ module ClassMethods
51
+ # Returns the delegator class that responds to all feature check methods and delegates other
52
+ # method calls to its wrapped object.
53
+ #
54
+ def internal_feature_delegator_class
55
+ @_internal_feature_delegator_class ||= Class.new(SimpleDelegator).tap { |klass|
56
+ klass.include internal_feature_module
57
+ }
58
+ end
59
+ end
60
+
61
+ module InstanceMethods
62
+ private def internal_feature_delegator_class
63
+ self.class.internal_feature_delegator_class
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ require_relative "serializable"
6
+
7
+ module Featuring
8
+ # Internal concerns related to defining feature flags on a module or class.
9
+ #
10
+ module Flaggable
11
+ def self.extended(object)
12
+ super
13
+
14
+ case object
15
+ when Class
16
+ object.include object.internal_feature_checks_module
17
+ when Module
18
+ object.extend object.internal_feature_checks_module
19
+ end
20
+ end
21
+
22
+ def self.setup_flaggable_object(object)
23
+ case object
24
+ when Class
25
+ object.extend ClassMethods
26
+ object.prepend InstanceMethods
27
+ when Module
28
+ object.extend Flaggable
29
+ object.extend Delegatable
30
+ object.extend Serializable
31
+ end
32
+ end
33
+
34
+ # Contains methods that return the feature's original default value, or block value.
35
+ #
36
+ def internal_feature_module
37
+ @_internal_feature_module ||= Module.new
38
+ end
39
+
40
+ # Contains methods that wrap the original values to typecast them into true or false values.
41
+ #
42
+ def internal_feature_checks_module
43
+ @_internal_feature_checks_module ||= Module.new
44
+ end
45
+
46
+ def define_feature_flag(name, default, &block)
47
+ # Define a feature check method that returns the default value or the block's return value.
48
+ #
49
+ internal_feature_module.module_eval do
50
+ if method_defined?(name)
51
+ undef_method(name)
52
+ end
53
+
54
+ if block
55
+ define_method name, &block
56
+ else
57
+ define_method name do
58
+ default
59
+ end
60
+ end
61
+ end
62
+
63
+ # Define a method that typecasts the value returned from the delegator to true/false. This is
64
+ # the method that's called when calling code asks if a feature is enabled. It guarantees that
65
+ # only true or false is returned when checking a feature flag.
66
+ #
67
+ internal_feature_checks_module.module_eval do
68
+ method_name = "#{name}?"
69
+
70
+ if method_defined?(method_name)
71
+ undef_method(method_name)
72
+ end
73
+
74
+ define_method method_name do |*args|
75
+ fetch_feature_flag_value(name, *args)
76
+ end
77
+ end
78
+
79
+ # Keep track of the feature flag we just defined.
80
+ #
81
+ feature_flags << name
82
+ end
83
+
84
+ def feature_flags
85
+ @_feature_flags ||= []
86
+ end
87
+
88
+ module ClassMethods
89
+ extend Forwardable
90
+
91
+ # Delegate flaggable methods to the internal `instance_feature_class`. This lets features be
92
+ # defined through the object, but actually be defined on the internal class.
93
+ #
94
+ def_delegators :instance_feature_class, :internal_feature_module, :internal_feature_checks_module, :define_feature_flag
95
+
96
+ # The internal class where feature flags for the object are defined. An instance of this class
97
+ # is returned when calling `object.features`. The object delegates all feature flag definition
98
+ # concerns to this internal class (see the comment above).
99
+ #
100
+ def instance_feature_class
101
+ @_instance_feature_class ||= Class.new do
102
+ extend Flaggable
103
+
104
+ # The class is `Flaggable`, but *instances* are `Delegatable`. This lets us delegate
105
+ # dynamically to the parent object (the object `features` is called on).
106
+ #
107
+ include Delegatable
108
+
109
+ include Serializable
110
+
111
+ def initialize(parent)
112
+ @parent = parent
113
+ end
114
+
115
+ # @api private
116
+ def feature_flags
117
+ self.class.feature_flags
118
+ end
119
+
120
+ private def internal_feature_delegates_to
121
+ @parent
122
+ end
123
+ end
124
+ end
125
+
126
+ def inherited(object)
127
+ # Add the feature check methods to the object's internal feature class.
128
+ #
129
+ object.instance_feature_class.internal_feature_checks_module.include instance_feature_class.internal_feature_checks_module
130
+
131
+ # Because we added feature check methods above, include again to make them available.
132
+ #
133
+ object.instance_feature_class.include object.instance_feature_class.internal_feature_checks_module
134
+
135
+ # Add the feature methods to the object's internal feature class.
136
+ #
137
+ object.instance_feature_class.internal_feature_module.include instance_feature_class.internal_feature_module
138
+ end
139
+ end
140
+
141
+ module InstanceMethods
142
+ # Returns the object's feature context.
143
+ #
144
+ def features
145
+ @_features ||= self.class.instance_feature_class.new(self)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../persistence"
4
+ require_relative "adapter"
5
+
6
+ module Featuring
7
+ module Persistence
8
+ # [public] Persists feature flag values using an ActiveRecord model. Postgres is currently the only
9
+ # supported database (see `Featuring::Persistence` for details on how to use persistence).
10
+ #
11
+ # class User < ActiveRecord::Base
12
+ # extend Featuring::Persistence::ActiveRecord
13
+ #
14
+ # extend Featuring::Declarable
15
+ # feature :some_feature
16
+ # end
17
+ #
18
+ # User.find(1).features.enable :some_feature
19
+ # User.find(1).features.some_feature?
20
+ # => true
21
+ #
22
+ module ActiveRecord
23
+ extend Adapter
24
+
25
+ # [public] Methods to be added to the flaggable object.
26
+ #
27
+ module Flaggable
28
+ def reload
29
+ features.reload
30
+
31
+ super
32
+ end
33
+ end
34
+
35
+ # [public] Returns the ActiveRecord model used to persist feature flag values.
36
+ #
37
+ def feature_flag_model
38
+ ::FeatureFlag
39
+ end
40
+
41
+ class << self
42
+ def fetch(target)
43
+ target.feature_flag_model.find_by(flaggable_id: target.id, flaggable_type: target.class.name)&.metadata
44
+ end
45
+
46
+ def create(target, **features)
47
+ target.feature_flag_model.create(
48
+ flaggable_id: target.id,
49
+ flaggable_type: target.class.name,
50
+ metadata: features
51
+ )
52
+ end
53
+
54
+ def update(target, **features)
55
+ scoped_dataset(target).update_all("metadata = metadata || '#{features.to_json}'")
56
+ end
57
+
58
+ def replace(target, **features)
59
+ scoped_dataset(target).update_all("metadata = '#{features.to_json}'")
60
+ end
61
+
62
+ private def scoped_dataset(target)
63
+ target.feature_flag_model.where(
64
+ flaggable_type: target.class.name,
65
+ flaggable_id: target.id
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end