featuring 1.0.0

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.
@@ -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