togglefy 1.0.2 → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f78d7324689b7cacb720114af6d9ef3a8f472f8da8a572860855a77b32cdea7
4
- data.tar.gz: a1861e9b87081fdce869358ad64d7bf9b653296d79363fe3765aab1947929a09
3
+ metadata.gz: 2419d9b30bf9193f8262fb0437f128510be4b00a0556a0fb244cf6b316299bb5
4
+ data.tar.gz: 8e1b0f85f9aedd05eb5880277f6657eee36e1c570a768f3a037b9a1a4137852c
5
5
  SHA512:
6
- metadata.gz: 2cf79e274cea2c678f2b1719f7a570b1ed428dca4570dac9d30c7207396c71bd38fd45a77111c04a3f6b66e7845a1f9d4b61b72607e6713920024117a547592d
7
- data.tar.gz: c383ec661ba05a10a9dbb9c0fd8b4ac46c27aeb71fa0371193674bf26a4fcaee43ea8684ffe73cac1ed1e92300b3c5f7c55b6949d1160de3a2b73c06a5610eec
6
+ metadata.gz: c40f79f61887796ac7e646c0db064fc4a90862e3eea7260597d10eb6cad57543378e7ed6ca4ef0fd8e51efdc8539147035501419de7007bc07d47c63da755d3d
7
+ data.tar.gz: 9cb7c91cc395acc224d1fa19533e86d9c899b15e61c56f05e608f0dc74d2d9f1392e3f1bf78a9d92790d67440e0b001411a2309f983134ddfe555f6ad6e5d2a1
data/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Togglefy
2
2
 
3
+ ![Gem](https://img.shields.io/gem/v/togglefy)
4
+ ![Downloads](https://img.shields.io/gem/dt/togglefy)
5
+
3
6
  Togglefy is a simple feature management solution to help you control which features an user or a group has access to.
4
7
 
5
8
  Togglefy is free, open source and you are welcome to help build it.
@@ -9,7 +12,7 @@ Togglefy is free, open source and you are welcome to help build it.
9
12
  Add the gem manually to your Gemfile:
10
13
 
11
14
  ```gemfile
12
- gem 'togglefy', '~> 1.0', '>= 1.0.1'
15
+ gem 'togglefy', '~> 1.0', '>= 1.1.0'
13
16
  ```
14
17
 
15
18
  Or install it and add to the application's Gemfile by executing:
@@ -55,14 +58,18 @@ After that, the next steps are also pretty simple.
55
58
 
56
59
  Add the following to your model that will have a relation with the features. It can be an `User`, `Account` or something you decide:
57
60
  ```ruby
58
- include Togglefy::Featureable
61
+ include Togglefy::Assignable
59
62
  ```
60
63
 
61
- This will add the relationship between Togglefy's models and yours. Yours will be referred to as **assignable** throughout this documentation. If you want to check it in the source code, you can find it here: `lib/togglefy/featureable.rb` inside de `included` block.
64
+ This will add the relationship between Togglefy's models and yours. Yours will be referred to as **assignable** throughout this documentation. If you want to check it in the source code, you can find it here: `lib/togglefy/assignable.rb` inside de `included` block.
65
+
66
+ Older versions (`<= 1.0.2`) had the `Featureable` instead of the `Assignable`. The `Featureable` is now deprecated. You can still use it and it won't impact old users, but we highly recommend you to use the `Assignable` as it is semantically more accurate about what it does.
62
67
 
63
68
  With that, everything is ready to use **Togglefy**, welcome!
64
69
 
65
- ### Creating Features
70
+ ### Managing Features
71
+
72
+ #### Creating Features
66
73
  To create features it's as simple as drinking a nice cold beer after a hard day or drinking the entire bottle of coffee in a span of 1 hour:
67
74
 
68
75
  ```ruby
@@ -84,6 +91,24 @@ Togglefy::Feature.create(
84
91
  )
85
92
  ```
86
93
 
94
+ You can also use:
95
+
96
+ ```ruby
97
+ Togglefy.create(
98
+ name: "Teleportation",
99
+ description: "Allows the assignable to teleport to anywhere",
100
+ group: :jumper
101
+ )
102
+
103
+ # Or
104
+
105
+ Togglefy.create_feature(
106
+ name: "Teleportation",
107
+ description: "Allows the assignable to teleport to anywhere",
108
+ group: :jumper
109
+ )
110
+ ```
111
+
87
112
  You don't have to fill all fields, the only one that is mandatory is the name, because is by using the name that we will create the unique identifier, which is the field we'll use to find, delete and more.
88
113
 
89
114
  The identifier is the name, downcased and snake_cased 🐍
@@ -97,17 +122,60 @@ identifier: "super_powers",
97
122
  description: "With great power comes great responsibility",
98
123
  created_at: "2025-04-12 01:39:10.176561000 +0000",
99
124
  updated_at: "2025-04-12 01:39:46.818928000 +0000",
100
- tenant: "123abc",
125
+ tenant_id: "123abc",
101
126
  group: "admin",
102
127
  environment: "production",
103
128
  status: "inactive"
104
129
  ```
105
130
 
131
+ #### Updating a Feature
132
+
133
+ To update a feature is as simple as riding on a monocycle while balancing a cup of water on the top of a really tall person that's on your shoulders.
134
+
135
+ Here's how you can do it:
136
+
137
+ ```ruby
138
+ Togglefy.update(:super_powers, tenant_id: "abc123")
139
+ Togglefy.update_feature(:super_powers, tenant_id: "abc123")
140
+ ```
141
+
142
+ Or by finding the feature manually and then updating it like you always do with Rails:
143
+
144
+ ```ruby
145
+ feature = Togglefy::Feature.find_by(identifier: :super_powers)
146
+ # or
147
+ feature = Togglefy.feature(:super_powers) # This is explained more in the "Finding a specific feature" section of this README
148
+
149
+ feature.update(tenant_id: "123abc")
150
+ ```
151
+
152
+ #### Destroying Features
153
+
154
+ It looks like you're mean 😈
155
+
156
+ So here's how you can destroy a feature:
157
+
158
+ ```ruby
159
+ Togglefy.destroy(:super_powers)
160
+ Togglefy.destroy_feature(:super_powers)
161
+ Togglefy::Feature.identifier(:super_powers).destroy
162
+ Togglefy::Feature.find_by(identifier: :super_powers).destroy
163
+ ```
164
+
165
+ #### Toggle Features
166
+
167
+ You can toggle a feature status to active or inactive by doing this:
168
+
169
+ ```ruby
170
+ Togglefy.toggle(:super_powers)
171
+ Togglefy.toggle_feature(:super_powers)
172
+ ```
173
+
106
174
  #### About `Togglefy::Feature` status
107
175
 
108
176
  As you can see, the `Togglefy::Feature` also has a status and it is default to `inactive`. You can change this during creation.
109
177
 
110
- The status holds the `inactive` or `active` values. This status is not to define if a assignable (any model that has the `include Togglefy::Featureable`) either has ou hasn't a feature, but to decide if this feature is available in the entire system.
178
+ The status holds the `inactive` or `active` values. This status is not to define if a assignable (any model that has the `include Togglefy::Assignable`) either has ou hasn't a feature, but to decide if this feature is available in the entire system.
111
179
 
112
180
  It's up to you to define how you will implement it.
113
181
 
@@ -121,14 +189,21 @@ You can change the status by:
121
189
  * Updating the column
122
190
  * Doing a:
123
191
  ```ruby
124
- feature.active! # To activate
125
- feature.inactive! # To inactivate
192
+ Togglefy::Feature.find_by(identifier: :super_powers).active!
193
+ Togglefy.feature(:super_powers).active!
194
+ Togglefy.active!(:super_powers)
195
+ Togglefy.activate_feature(:super_powers)
196
+
197
+ Togglefy::Feature.find_by(identifier: :super_powers).inactive!
198
+ Togglefy.feature(:super_powers).inactive!
199
+ Togglefy.inactive!(:super_powers)
200
+ Togglefy.inactivate_feature(:super_powers)
126
201
  ```
127
202
 
128
203
  ### Managing Assignables <-> Features
129
204
  Now that we know how to create features, let's check how we can manage them.
130
205
 
131
- An assignable has some direct methods thanks to the `include Togglefy::Featureable`, which are (and let's use an user as an example of an assignable):
206
+ An assignable has some direct methods thanks to the `include Togglefy::Assignable`, which are (and let's use an user as an example of an assignable):
132
207
 
133
208
  ```ruby
134
209
  user.has_feature?(:super_powers) # Checks if user has a single feature
@@ -139,7 +214,7 @@ user.clear_features # Clears all features from an user
139
214
 
140
215
  The assignable <-> feature relation is held by the `Togglefy::FeatureAssignment` table/model.
141
216
 
142
- But there's another way to manage assignables <-> features by using the `FeatureManager`. It's up to you to decide which one.
217
+ But there's another way to manage assignables <-> features by using the `FeatureAssignableManager`. It's up to you to decide which one.
143
218
 
144
219
  Here are the examples:
145
220
 
@@ -150,8 +225,59 @@ Togglefy.for(assignable).disable(:super_powers) # Disables/removes a feature fro
150
225
  Togglefy.for(assignable).clear # Clears all features from an assignable
151
226
  ```
152
227
 
228
+ You can also supercharge it by chaining methods, like:
229
+
230
+ ```ruby
231
+ # Instead of doing this:
232
+ Togglefy.for(assignable).disable(:alpha_access)
233
+ Togglefy.for(assignable).enable(:beta_access)
234
+
235
+ # You can go something like this:
236
+ Togglefy.for(assignable).disable(:alpha_access).enable(:beta_access)
237
+ ```
238
+
153
239
  This second method may look strange, but it's the default used by the gem and you will see that right now!
154
240
 
241
+ #### Mass Enable/Disable of Features to/from Assignables
242
+ You can mass enable/disable features to/from Assignables.
243
+
244
+ Doing that is simple! Let's assume that your assignable is an User model.
245
+
246
+ ```ruby
247
+ Togglefy.mass_for(User).bulk.enable(:super_powers) # To enable a specific feature to all users
248
+ Togglefy.mass_for(User).bulk.enable([:super_powers, :magic, ...]) # To enable two or more features to all users
249
+ Togglefy.mass_for(User).bulk.enable(:super_powers, percentage: 20) # To enable a feature to 20% of all users
250
+ Togglefy.mass_for(User).bulk.enable([:super_powers, :magic, ...], percentage: 50) # To enable two or more features to 50% of all users
251
+ ```
252
+
253
+ The same applies to the disable:
254
+
255
+ ```ruby
256
+ Togglefy.mass_for(User).bulk.disable(:super_powers) # To disable a specific feature to all users
257
+ Togglefy.mass_for(User).bulk.disable([:super_powers, :magic, ...]) # To disable two or more features to all users
258
+ Togglefy.mass_for(User).bulk.disable(:super_powers, percentage: 5) # To disable a feature to 5% of all users
259
+ Togglefy.mass_for(User).bulk.disable([:super_powers, :magic, ...], percentage: 75) # To disable two or more features to 75% of all users
260
+ ```
261
+
262
+ There are a few things to pay attention:
263
+ * Whenever you do a enable/disable, it will only query for valid assignables, so:
264
+ * If you do a enable, it will query all assignables that don't have the feature(s) enabled
265
+ * If you do a disable, it will query all assignables that do already have the feature(s) enabled
266
+ * You can also use filters for:
267
+ * `group || role`
268
+ * `environment || env`
269
+ * `tenant_id`
270
+ * You can check about filters aliases at [Aliases table](#aliases-table)
271
+
272
+ These will be applied to query features that match the identifiers + the filters sent.
273
+
274
+ So it would be something like:
275
+
276
+ ```ruby
277
+ Togglefy.mass_for(User).bulk.enable(:super_powers, group: :admin, percentage: 20)
278
+ Togglefy.mass_for(User).bulk.disable(:magic, group: :dev, env: :production, percentage: 75)
279
+ ```
280
+
155
281
  ### Querying Features
156
282
  Remember when I told you a looooong time ago that the strange way is the default using by the gem? If you don't, no worries. It was a really loooong time ago, like `1.minute.ago`.
157
283
 
@@ -178,12 +304,28 @@ Togglefy.for_filters(filters: {group: :admin, environment: :production})
178
304
 
179
305
  This will query me all `Togglefy::Feature`s that belongs to group admin and the production environment.
180
306
 
307
+ The `Togglefy.for_filters` can have the following filters:
308
+
309
+ ```ruby
310
+ filters: {
311
+ group:,
312
+ role:, # Acts as a group, explained in the Aliases section
313
+ environment:,
314
+ env:, # Acts as a group, explained in the Aliases section
315
+ tenant_id:,
316
+ status:,
317
+ identifier:
318
+ }
319
+ ```
320
+
321
+ The `Togglefy.for_filters` will only apply the filters sent that are `nil` or `!value.blank?`.
322
+
181
323
  You can send `nil` values too, like:
182
324
 
183
325
  ```ruby
184
326
  Togglefy.for_tenant(nil) # This will query me all Togglefy::Features with tenant_id nil
185
327
 
186
- Togglefy.for_filters(filters: {group: :admin, environment: :nil, tenant_id: nil})
328
+ Togglefy.for_filters(filters: {group: :admin, environment: nil, tenant_id: nil})
187
329
  ```
188
330
 
189
331
  There's also another way to filter for `nil` values:
@@ -210,13 +352,39 @@ Togglefy.with_status(:active)
210
352
  #### Finding a specific feature
211
353
  ```ruby
212
354
  Togglefy.feature(:super_powers)
355
+ Togglefy::Feature.identifier(:super_powers)
213
356
  Togglefy::Feature.find_by(identifier: :super_powers)
214
357
  ```
215
358
 
216
- #### Finding a specific feature just to destroy it because you're mean 😈
359
+ #### Finding multiple features
217
360
  ```ruby
218
- Togglefy.destroy_feature(:super_powers)
219
- Togglefy::Feature.find_by(identifier: :super_powers).destroy
361
+ Togglefy.feature([:super_powers, :magic])
362
+ Togglefy::Feature.identifier([:super_powers, :magic])
363
+ Togglefy::Feature.where(identifier: [:super_powers, :magic])
364
+ ```
365
+
366
+ #### List all features of an Assignable
367
+ Let's pretend that your assignable is an User.
368
+
369
+ ```ruby
370
+ user = User.find(1)
371
+ user.features
372
+ ```
373
+
374
+ #### Check all features an Assignable have/doesn't have
375
+ Again, let's pretend that your assignable is an User. This is the only case you need to send the feature id and not the identifier.
376
+
377
+ ```ruby
378
+ User.with_features(1)
379
+ User.with_features([2, 3])
380
+ User.without_features(1)
381
+ User.without_features([2, 3])
382
+ ```
383
+
384
+ #### Querying all features
385
+ ```ruby
386
+ Togglefy.features(:super_powers)
387
+ Togglefy::Feature.all
220
388
  ```
221
389
 
222
390
  #### Querying all features enabled to a klass
@@ -225,7 +393,7 @@ Let's assume that you have two different assignables: User and Account.
225
393
  You want to list all features being used by assignables of User type:
226
394
 
227
395
  ```ruby
228
- Togglefy.for_type(User) # This is return all current FeatureAssignment with a User assignable
396
+ Togglefy.for_type(User) # This returns all current FeatureAssignment with a User assignable
229
397
  ```
230
398
 
231
399
  #### Aliases
@@ -248,17 +416,39 @@ Togglefy.for_filters(filters: {environment: :production})
248
416
  Togglefy.for_filters(filter: {env: :production})
249
417
  ```
250
418
 
419
+ ## Aliases table
420
+
421
+ Here's a table of all aliases available on Togglefy.
422
+
423
+ You can use either, as long as you respect the rules listed.
424
+
425
+ | Original | Alias | Rules |
426
+ | --------------------- | -------------------- | --------------------------------------------------- |
427
+ | `for_group` | `for_role` | Can't be used if doing a direct `Togglefy::Feature` |
428
+ | `without_group` | `without_role` | Can't be used if doing a direct `Togglefy::Feature` |
429
+ | `for_environment` | `for_env` | Can't be used if doing a direct `Togglefy::Feature` |
430
+ | `without_environment` | `without_env` | Can't be used if doing a direct `Togglefy::Feature` |
431
+ | `group` | `role` | Used inside methods that receives filters |
432
+ | `environment` | `env` | Used inside methods that receives filters |
433
+ | `create` | `create_feature` | None |
434
+ | `update` | `update_feature` | None |
435
+ | `toggle` | `toggle_feature` | None |
436
+ | `active!` | `activate_feature` | None |
437
+ | `deactive!` | `inactivate_feature` | None |
438
+ | `destroy` | `destroy_feature` | None |
439
+
440
+
251
441
  ## Development
252
442
 
253
443
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
254
444
 
255
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
445
+ To install this gem onto your local machine, run `bundle exec rake install`.
256
446
 
257
447
  ## Contributing
258
448
 
259
449
  Bug reports and pull requests are welcome on GitHub at https://github.com/azeveco/togglefy. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/azeveco/togglefy/blob/master/CODE_OF_CONDUCT.md).
260
450
 
261
- There's a PR Template. Its usa is highly encouraged.
451
+ There's a PR Template. Its use is highly encouraged.
262
452
 
263
453
  ## License
264
454
 
@@ -7,6 +7,8 @@ module Togglefy
7
7
 
8
8
  before_validation :build_identifier
9
9
 
10
+ scope :identifier, ->(identifier) { where(identifier:) }
11
+
10
12
  scope :for_group, ->(group) { where(group:) }
11
13
  scope :without_group, -> { where(group: nil) }
12
14
 
@@ -0,0 +1,66 @@
1
+ require "active_support/concern"
2
+
3
+ module Togglefy
4
+ module Assignable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ has_many :feature_assignments, as: :assignable, class_name: "Togglefy::FeatureAssignment"
9
+ has_many :features, through: :feature_assignments, class_name: "Togglefy::Feature"
10
+
11
+ scope :with_features, ->(feature_ids) {
12
+ joins(:feature_assignments)
13
+ .where(feature_assignments: {
14
+ feature_id: feature_ids
15
+ })
16
+ .distinct
17
+ }
18
+
19
+ scope :without_features, ->(feature_ids) {
20
+ joins(left_join_on_features(feature_ids))
21
+ .where("fa.id IS NULL")
22
+ .distinct
23
+ }
24
+ end
25
+
26
+ def has_feature?(identifier)
27
+ features.exists?(identifier: identifier.to_s)
28
+ end
29
+
30
+ def add_feature(feature)
31
+ feature = find_feature!(feature)
32
+ features << feature unless has_feature?(feature.identifier)
33
+ end
34
+
35
+ def remove_feature(feature)
36
+ feature = find_feature!(feature)
37
+ features.destroy(feature) if has_feature?(feature.identifier)
38
+ end
39
+
40
+ def clear_features
41
+ features.destroy_all
42
+ end
43
+
44
+ private
45
+
46
+ def find_feature!(feature)
47
+ return feature if feature.is_a?(Togglefy::Feature)
48
+
49
+ Togglefy::Feature.find_by!(identifier: feature.to_s)
50
+ end
51
+
52
+ class_methods do
53
+ def left_join_on_features(feature_ids)
54
+ table = self.table_name
55
+ type = self.name
56
+
57
+ <<~SQL.squish
58
+ LEFT JOIN togglefy_feature_assignments fa
59
+ ON fa.assignable_id = #{table}.id
60
+ AND fa.assignable_type = '#{type}'
61
+ AND fa.feature_id IN (#{Array(feature_ids).join(",")})
62
+ SQL
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,7 @@
1
+ module Togglefy
2
+ class AssignablesNotFound < Togglefy::Error
3
+ def initialize(klass)
4
+ super("No #{klass.name} found matching features and filters sent")
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ module Togglefy
2
+ class BulkToggleFailed < Togglefy::Error
3
+ def initialize(message = "Bulk toggle operation failed", cause = nil)
4
+ super(message)
5
+ set_backtrace(cause.backtrace) if cause
6
+ @cause = cause
7
+ end
8
+
9
+ attr_reader :cause
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ module Togglefy
2
+ class DependencyMissing < Togglefy::Error
3
+ def initialize(feature, required)
4
+ super("Feature '#{feature}' is missing dependency: '#{required}'")
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,3 @@
1
+ module Togglefy
2
+ class Error < StandardError; end
3
+ end
@@ -0,0 +1,7 @@
1
+ module Togglefy
2
+ class FeatureNotFound < Togglefy::Error
3
+ def initialize
4
+ super("No features found matching features and/or filters sent")
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module Togglefy
2
+ class InvalidFeatureAttribute < Togglefy::Error
3
+ def initialize(attr)
4
+ super("The attribute '#{attr}' is not valid for Togglefy::Feature.")
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,5 @@
1
+ require "togglefy/errors/error"
2
+
3
+ require "togglefy/errors/feature_not_found"
4
+ require "togglefy/errors/assignables_not_found"
5
+ require "togglefy/errors/bulk_toggle_failed"
@@ -0,0 +1,30 @@
1
+ module Togglefy
2
+ class FeatureAssignableManager
3
+ def initialize(assignable)
4
+ @assignable = assignable
5
+ end
6
+
7
+ def enable(feature)
8
+ assignable.add_feature(feature)
9
+ self
10
+ end
11
+
12
+ def disable(feature)
13
+ assignable.remove_feature(feature)
14
+ self
15
+ end
16
+
17
+ def clear
18
+ assignable.clear_features
19
+ self
20
+ end
21
+
22
+ def has?(feature)
23
+ assignable.has_feature?(feature)
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :assignable
29
+ end
30
+ end
@@ -1,27 +1,41 @@
1
1
  module Togglefy
2
2
  class FeatureManager
3
- def initialize(assignable)
4
- @assignable = assignable
3
+ def initialize(identifier = nil)
4
+ @identifier = identifier unless identifier.nil?
5
5
  end
6
6
 
7
- def enable(feature)
8
- assignable.add_feature(feature)
7
+ def create(**params)
8
+ Togglefy::Feature.create!(**params)
9
9
  end
10
10
 
11
- def disable(feature)
12
- assignable.remove_feature(feature)
11
+ def update(**params)
12
+ feature.update!(**params)
13
13
  end
14
14
 
15
- def clear
16
- assignable.clear_features
15
+ def destroy
16
+ feature.destroy
17
17
  end
18
18
 
19
- def has?(feature)
20
- assignable.has_feature?(feature)
19
+ def toggle
20
+ return feature.inactive! if feature.active?
21
+
22
+ feature.active!
23
+ end
24
+
25
+ def active!
26
+ feature.active!
27
+ end
28
+
29
+ def inactive!
30
+ feature.inactive!
21
31
  end
22
32
 
23
33
  private
24
34
 
25
- attr_reader :assignable
35
+ attr_reader :identifier
36
+
37
+ def feature
38
+ Togglefy::Feature.find_by!(identifier:)
39
+ end
26
40
  end
27
- end
41
+ end
@@ -1,12 +1,13 @@
1
1
  module Togglefy
2
2
  class FeatureQuery
3
- def feature(identifier)
4
- Togglefy::Feature.find_by!(identifier:)
3
+ def features
4
+ Togglefy::Feature.all
5
5
  end
6
6
 
7
- def destroy_feature(identifier)
8
- feature = Togglefy::Feature.find_by!(identifier:)
9
- feature&.destroy
7
+ def feature(identifier)
8
+ return Togglefy::Feature.identifier(identifier) if identifier.is_a?(Array)
9
+
10
+ Togglefy::Feature.find_by!(identifier:)
10
11
  end
11
12
 
12
13
  def for_type(klass)
@@ -16,22 +17,18 @@ module Togglefy
16
17
  def for_group(group)
17
18
  Togglefy::Feature.for_group(group)
18
19
  end
19
- alias :for_role :for_group
20
20
 
21
21
  def without_group
22
22
  Togglefy::Feature.without_group
23
23
  end
24
- alias :without_role :without_group
25
24
 
26
25
  def for_environment(environment)
27
26
  Togglefy::Feature.for_environment(environment)
28
27
  end
29
- alias :for_env :for_environment
30
28
 
31
29
  def without_environment
32
30
  Togglefy::Feature.without_environment
33
31
  end
34
- alias :without_env :without_environment
35
32
 
36
33
  def for_tenant(tenant_id)
37
34
  Togglefy::Feature.for_tenant(tenant_id)
@@ -41,15 +38,27 @@ module Togglefy
41
38
  Togglefy::Feature.without_tenant
42
39
  end
43
40
 
41
+ def with_status(status)
42
+ Togglefy::Feature.with_status(status)
43
+ end
44
+
44
45
  def for_filters(filters)
45
46
  Togglefy::Feature
46
- .for_group(filters[:group] || filters[:role])
47
- .for_environment(filters[:environment] || filters[:env])
48
- .for_tenant(filters[:tenant_id])
47
+ .then { |q| safe_chain(q, :identifier, filters[:identifier], apply_if: filters.key?(:identifier)) }
48
+ .then { |q| safe_chain(q, :for_group, filters[:group] || filters[:role], apply_if: filters.key?(:group) || filters.key?(:role)) }
49
+ .then { |q| safe_chain(q, :for_environment, filters[:environment] || filters[:env], apply_if: filters.key?(:environment) || filters.key?(:env)) }
50
+ .then { |q| safe_chain(q, :for_tenant, filters[:tenant_id], apply_if: filters.key?(:tenant_id)) }
51
+ .then { |q| safe_chain(q, :with_status, filters[:status], apply_if: filters.key?(:status)) }
49
52
  end
53
+
54
+ private
50
55
 
51
- def with_status(status)
52
- Togglefy::Feature.with_status(status)
56
+ def safe_chain(query, method, value, apply_if: true)
57
+ apply_if && nil_or_not_blank?(value) ? query.public_send(method, value) : query
58
+ end
59
+
60
+ def nil_or_not_blank?(value)
61
+ value.nil? || !value.blank?
53
62
  end
54
63
  end
55
64
  end
@@ -1,36 +1,6 @@
1
- module Togglefy
2
- module Featureable
3
- extend ActiveSupport::Concern
4
-
5
- included do
6
- has_many :feature_assignments, as: :assignable, class_name: "Togglefy::FeatureAssignment"
7
- has_many :features, through: :feature_assignments, class_name: "Togglefy::Feature"
8
- end
9
-
10
- def has_feature?(identifier)
11
- features.exists?(identifier: identifier.to_s)
12
- end
13
-
14
- def add_feature(feature)
15
- feature = find_feature!(feature)
16
- features << feature unless has_feature?(feature.identifier)
17
- end
18
-
19
- def remove_feature(feature)
20
- feature = find_feature!(feature)
21
- features.destroy(feature) if has_feature?(feature.identifier)
22
- end
1
+ require "togglefy/assignable"
23
2
 
24
- def clear_features
25
- features.destroy_all
26
- end
27
-
28
- private
29
-
30
- def find_feature!(feature)
31
- return feature if feature.is_a?(Togglefy::Feature)
32
-
33
- Togglefy::Feature.find_by!(identifier: feature.to_s)
34
- end
35
- end
3
+ module Togglefy
4
+ Featureable = Assignable
5
+ warn "[DEPRECATION] `Togglefy::Featureable` is deprecated. Use `Togglefy::Assignable` instead."
36
6
  end
@@ -0,0 +1,13 @@
1
+ require "togglefy/services/bulk_toggler"
2
+
3
+ module Togglefy
4
+ class ScopedBulkWrapper
5
+ def initialize(klass)
6
+ @klass = klass
7
+ end
8
+
9
+ def bulk
10
+ BulkToggler.new(@klass)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,108 @@
1
+ module Togglefy
2
+ class BulkToggler
3
+ ALLOWED_ASSIGNABLE_FILTERS = %i[group role environment env tenant_id].freeze
4
+
5
+ def initialize(klass)
6
+ @klass = klass
7
+ end
8
+
9
+ def enable(identifiers, **filters)
10
+ toggle(:enable, identifiers, filters)
11
+ end
12
+
13
+ def disable(identifiers, **filters)
14
+ toggle(:disable, identifiers, filters)
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :klass
20
+
21
+ def toggle(action, identifiers, filters)
22
+ identifiers = Array(identifiers)
23
+ features = Togglefy.for_filters(
24
+ filters: {identifier: identifiers}.merge(build_scope_filters(filters))
25
+ ).to_a
26
+
27
+ raise Togglefy::FeatureNotFound if features.empty?
28
+
29
+ feature_ids = features.map(&:id)
30
+
31
+ assignables = if action == :enable
32
+ klass.without_features(feature_ids)
33
+ else
34
+ klass.with_features(feature_ids)
35
+ end
36
+
37
+ raise Togglefy::AssignablesNotFound.new(klass, identifiers, filters) if assignables.empty?
38
+
39
+ assignables = sample_assignables(assignables, filters[:percentage]) if filters[:percentage]
40
+
41
+ existing_assignments = Togglefy::FeatureAssignment.where(
42
+ assignable_id: assignables.map(&:id),
43
+ assignable_type: klass.name,
44
+ feature_id: features.map(&:id)
45
+ ).pluck(:assignable_id, :feature_id)
46
+
47
+ existing_lookup = Set.new(existing_assignments)
48
+
49
+ if action == :enable
50
+ rows = []
51
+
52
+ assignables.each do |assignable|
53
+ features.each do |feature|
54
+ key = [assignable.id, feature.id]
55
+ next if existing_lookup.include?(key)
56
+
57
+ rows << {
58
+ assignable_id: assignable.id,
59
+ assignable_type: assignable.class.name,
60
+ feature_id: feature.id
61
+ }
62
+ end
63
+ end
64
+
65
+ begin
66
+ Togglefy::FeatureAssignment.insert_all(rows) if rows.any?
67
+ rescue => error
68
+ raise Togglefy::BulkToggleFailed.new(
69
+ "Bulk toggle enable failed for #{klass.name} with identifiers #{identifiers.inspect}",
70
+ error
71
+ )
72
+ end
73
+ elsif action == :disable
74
+ ids_to_remove = []
75
+ assignables.each do |assignable|
76
+ features.each do |feature|
77
+ key = [assignable.id, feature.id]
78
+ ids_to_remove << key if existing_lookup.include?(key)
79
+ end
80
+ end
81
+
82
+ begin
83
+ if ids_to_remove.any?
84
+ Togglefy::FeatureAssignment.where(
85
+ assignable_id: ids_to_remove.map(&:first),
86
+ assignable_type: klass.name,
87
+ feature_id: ids_to_remove.map(&:last)
88
+ ).delete_all
89
+ end
90
+ rescue => error
91
+ raise Togglefy::BulkToggleFailed.new(
92
+ "Bulk toggle disable failed for #{klass.name} with identifiers #{identifiers.inspect}",
93
+ error
94
+ )
95
+ end
96
+ end
97
+ end
98
+
99
+ def build_scope_filters(filters)
100
+ filters.slice(*ALLOWED_ASSIGNABLE_FILTERS).compact
101
+ end
102
+
103
+ def sample_assignables(assignables, percentage)
104
+ count = (assignables.size * percentage.to_f / 100).round
105
+ assignables.sample(count)
106
+ end
107
+ end
108
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Togglefy
4
- VERSION = "1.0.2"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/togglefy.rb CHANGED
@@ -1,24 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "togglefy/version"
4
- require "togglefy/engine"
4
+ require "togglefy/engine" if defined?(Rails)
5
5
  require "togglefy/featureable"
6
+ require "togglefy/assignable"
7
+ require "togglefy/feature_assignable_manager"
6
8
  require "togglefy/feature_manager"
7
9
  require "togglefy/feature_query"
10
+ require "togglefy/scoped_bulk_wrapper"
11
+ require "togglefy/exceptions"
8
12
 
9
13
  module Togglefy
10
14
  class Error < StandardError; end
11
15
 
12
- def self.feature(identifier)
13
- FeatureQuery.new.feature(identifier)
16
+ # FeatureQuery
17
+ def self.features
18
+ FeatureQuery.new.features
14
19
  end
15
20
 
16
- def self.destroy_feature(identifier)
17
- FeatureQuery.new.destroy_feature(identifier)
18
- end
19
-
20
- def self.for(assignable)
21
- FeatureManager.new(assignable)
21
+ def self.feature(identifier)
22
+ FeatureQuery.new.feature(identifier)
22
23
  end
23
24
 
24
25
  def self.for_type(klass)
@@ -57,11 +58,55 @@ module Togglefy
57
58
  FeatureQuery.new.with_status(status)
58
59
  end
59
60
 
61
+ # FeatureManager
62
+ def self.create(**params)
63
+ FeatureManager.new.create(**params)
64
+ end
65
+
66
+ def self.update(identifier, **params)
67
+ FeatureManager.new(identifier).update(**params)
68
+ end
69
+
70
+ def self.destroy(identifier)
71
+ FeatureManager.new(identifier).destroy
72
+ end
73
+
74
+ def self.toggle(identifier)
75
+ FeatureManager.new(identifier).toggle
76
+ end
77
+
78
+ def self.active!(identifier)
79
+ FeatureManager.new(identifier).active!
80
+ end
81
+
82
+ def self.inactive!(identifier)
83
+ FeatureManager.new(identifier).inactive!
84
+ end
85
+
86
+ # FeatureAssignableManager
87
+ def self.for(assignable)
88
+ FeatureAssignableManager.new(assignable)
89
+ end
90
+
91
+ # ScopedBulkWrapper
92
+ def self.mass_for(klass)
93
+ Togglefy::ScopedBulkWrapper.new(klass)
94
+ end
95
+
60
96
  class <<self
97
+ # FeatureQuery
61
98
  alias_method :for_role, :for_group
62
99
  alias_method :without_role, :without_group
63
100
 
64
101
  alias_method :for_env, :for_environment
65
102
  alias_method :without_env, :without_environment
103
+
104
+ # FeatureManager
105
+ alias_method :create_feature, :create
106
+ alias_method :update_feature, :update
107
+ alias_method :toggle_feature, :toggle
108
+ alias_method :activate_feature, :active!
109
+ alias_method :inactivate_feature, :inactive!
110
+ alias_method :destroy_feature, :destroy
66
111
  end
67
112
  end
data/togglefy.gemspec CHANGED
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
9
9
  spec.email = ["gazeveco@gmail.com"]
10
10
 
11
11
  spec.summary = "Simple and open source Feature Management."
12
- spec.description = "Togglefy is a feature management solution to help you control which features an user or a group has access to."
12
+ spec.description = "Togglefy is a feature management Rails gem to help you control which features an user or a group has access to."
13
13
  spec.homepage = "https://github.com/azeveco/Togglefy"
14
14
  spec.license = "MIT"
15
15
  spec.required_ruby_version = ">= 2.4.0"
@@ -31,10 +31,4 @@ Gem::Specification.new do |spec|
31
31
  spec.bindir = "exe"
32
32
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
33
  spec.require_paths = ["lib"]
34
-
35
- # Uncomment to register a new dependency of your gem
36
- # spec.add_dependency "example-gem", "~> 1.0"
37
-
38
- # For more information and examples about making a new gem, check out our
39
- # guide at: https://bundler.io/guides/creating_gem.html
40
34
  end
metadata CHANGED
@@ -1,16 +1,16 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: togglefy
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriel Azevedo
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-04-12 00:00:00.000000000 Z
10
+ date: 2025-04-16 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: Togglefy is a feature management solution to help you control which features
13
- an user or a group has access to.
12
+ description: Togglefy is a feature management Rails gem to help you control which
13
+ features an user or a group has access to.
14
14
  email:
15
15
  - gazeveco@gmail.com
16
16
  executables: []
@@ -25,10 +25,21 @@ files:
25
25
  - lib/generators/togglefy/templates/create_feature_assignments.rb
26
26
  - lib/generators/togglefy/templates/create_features.rb
27
27
  - lib/togglefy.rb
28
+ - lib/togglefy/assignable.rb
28
29
  - lib/togglefy/engine.rb
30
+ - lib/togglefy/errors/assignables_not_found.rb
31
+ - lib/togglefy/errors/bulk_toggle_failed.rb
32
+ - lib/togglefy/errors/dependency_missing.rb
33
+ - lib/togglefy/errors/error.rb
34
+ - lib/togglefy/errors/feature_not_found.rb
35
+ - lib/togglefy/errors/invalid_feature_attribute.rb
36
+ - lib/togglefy/exceptions.rb
37
+ - lib/togglefy/feature_assignable_manager.rb
29
38
  - lib/togglefy/feature_manager.rb
30
39
  - lib/togglefy/feature_query.rb
31
40
  - lib/togglefy/featureable.rb
41
+ - lib/togglefy/scoped_bulk_wrapper.rb
42
+ - lib/togglefy/services/bulk_toggler.rb
32
43
  - lib/togglefy/version.rb
33
44
  - togglefy.gemspec
34
45
  homepage: https://github.com/azeveco/Togglefy