togglefy 1.0.2 → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f78d7324689b7cacb720114af6d9ef3a8f472f8da8a572860855a77b32cdea7
4
- data.tar.gz: a1861e9b87081fdce869358ad64d7bf9b653296d79363fe3765aab1947929a09
3
+ metadata.gz: 48668b30d043e271706d2264cba4c2ff6917bd36bc11aed4e8aee24aa9431917
4
+ data.tar.gz: f1d0543de69758fb5ef769b5b509901d4b9d1ae263d5f362489c7e099b0bc840
5
5
  SHA512:
6
- metadata.gz: 2cf79e274cea2c678f2b1719f7a570b1ed428dca4570dac9d30c7207396c71bd38fd45a77111c04a3f6b66e7845a1f9d4b61b72607e6713920024117a547592d
7
- data.tar.gz: c383ec661ba05a10a9dbb9c0fd8b4ac46c27aeb71fa0371193674bf26a4fcaee43ea8684ffe73cac1ed1e92300b3c5f7c55b6949d1160de3a2b73c06a5610eec
6
+ metadata.gz: e9ef4ce6770dc88b80b9a4a27b3ce4b43cfa8484b4115f6c6e1451065721e248f0fd0464b881c6edfacfa57f93dae213e10157910b4fc44bc93aa06f4aa3a724
7
+ data.tar.gz: 8181535e9663feb971707ac2d6806020c13e15745fc647508da0d0f578f57cb298fdd98927b90e525837c37b7f7ec5249d1eaab0eb5be580dd068d39e7a19f23
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:
@@ -34,7 +37,37 @@ After adding the gem to your project, you need to run the generate command to ad
34
37
  rails generate togglefy:install
35
38
  ```
36
39
 
37
- This command will create the migrations to create the tables inside your project. Please, don't remove/change anything that's there or Togglefy may not work as expected.
40
+ This command will create the migrations to create the tables inside your project.
41
+
42
+ If you use an older version of Rails (< 5), then the migration files don't need you to specify the version.
43
+
44
+ To fix this, you will have to manually go to the two migration files of Togglefy: `create_feature` and `create_feature_assignments` and do the following:
45
+
46
+ Change these lines from this:
47
+
48
+ ```ruby
49
+ rails_version = "#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}"
50
+
51
+ class CreateTogglefyFeatures < ActiveRecord::Migration[rails_version]
52
+ ```
53
+
54
+ ```ruby
55
+ rails_version = "#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}"
56
+
57
+ class CreateTogglefyFeatureAssignments < ActiveRecord::Migration[rails_version]
58
+ ```
59
+
60
+ To this:
61
+
62
+ ```ruby
63
+ class CreateTogglefyFeatures < ActiveRecord::Migration
64
+ ```
65
+
66
+ ```ruby
67
+ class CreateTogglefyFeatureAssignments < ActiveRecord::Migration
68
+ ```
69
+
70
+ Please, don't remove/change anything else that's there or Togglefy may not work as expected.
38
71
 
39
72
  Run the migration to create these in your datase:
40
73
  ```bash
@@ -55,14 +88,18 @@ After that, the next steps are also pretty simple.
55
88
 
56
89
  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
90
  ```ruby
58
- include Togglefy::Featureable
91
+ include Togglefy::Assignable
59
92
  ```
60
93
 
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.
94
+ 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.
95
+
96
+ 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
97
 
63
98
  With that, everything is ready to use **Togglefy**, welcome!
64
99
 
65
- ### Creating Features
100
+ ### Managing Features
101
+
102
+ #### Creating Features
66
103
  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
104
 
68
105
  ```ruby
@@ -84,6 +121,24 @@ Togglefy::Feature.create(
84
121
  )
85
122
  ```
86
123
 
124
+ You can also use:
125
+
126
+ ```ruby
127
+ Togglefy.create(
128
+ name: "Teleportation",
129
+ description: "Allows the assignable to teleport to anywhere",
130
+ group: :jumper
131
+ )
132
+
133
+ # Or
134
+
135
+ Togglefy.create_feature(
136
+ name: "Teleportation",
137
+ description: "Allows the assignable to teleport to anywhere",
138
+ group: :jumper
139
+ )
140
+ ```
141
+
87
142
  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
143
 
89
144
  The identifier is the name, downcased and snake_cased 🐍
@@ -97,17 +152,60 @@ identifier: "super_powers",
97
152
  description: "With great power comes great responsibility",
98
153
  created_at: "2025-04-12 01:39:10.176561000 +0000",
99
154
  updated_at: "2025-04-12 01:39:46.818928000 +0000",
100
- tenant: "123abc",
155
+ tenant_id: "123abc",
101
156
  group: "admin",
102
157
  environment: "production",
103
158
  status: "inactive"
104
159
  ```
105
160
 
161
+ #### Updating a Feature
162
+
163
+ 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.
164
+
165
+ Here's how you can do it:
166
+
167
+ ```ruby
168
+ Togglefy.update(:super_powers, tenant_id: "abc123")
169
+ Togglefy.update_feature(:super_powers, tenant_id: "abc123")
170
+ ```
171
+
172
+ Or by finding the feature manually and then updating it like you always do with Rails:
173
+
174
+ ```ruby
175
+ feature = Togglefy::Feature.find_by(identifier: :super_powers)
176
+ # or
177
+ feature = Togglefy.feature(:super_powers) # This is explained more in the "Finding a specific feature" section of this README
178
+
179
+ feature.update(tenant_id: "123abc")
180
+ ```
181
+
182
+ #### Destroying Features
183
+
184
+ It looks like you're mean 😈
185
+
186
+ So here's how you can destroy a feature:
187
+
188
+ ```ruby
189
+ Togglefy.destroy(:super_powers)
190
+ Togglefy.destroy_feature(:super_powers)
191
+ Togglefy::Feature.identifier(:super_powers).destroy
192
+ Togglefy::Feature.find_by(identifier: :super_powers).destroy
193
+ ```
194
+
195
+ #### Toggle Features
196
+
197
+ You can toggle a feature status to active or inactive by doing this:
198
+
199
+ ```ruby
200
+ Togglefy.toggle(:super_powers)
201
+ Togglefy.toggle_feature(:super_powers)
202
+ ```
203
+
106
204
  #### About `Togglefy::Feature` status
107
205
 
108
206
  As you can see, the `Togglefy::Feature` also has a status and it is default to `inactive`. You can change this during creation.
109
207
 
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.
208
+ 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
209
 
112
210
  It's up to you to define how you will implement it.
113
211
 
@@ -121,14 +219,21 @@ You can change the status by:
121
219
  * Updating the column
122
220
  * Doing a:
123
221
  ```ruby
124
- feature.active! # To activate
125
- feature.inactive! # To inactivate
222
+ Togglefy::Feature.find_by(identifier: :super_powers).active!
223
+ Togglefy.feature(:super_powers).active!
224
+ Togglefy.active!(:super_powers)
225
+ Togglefy.activate_feature(:super_powers)
226
+
227
+ Togglefy::Feature.find_by(identifier: :super_powers).inactive!
228
+ Togglefy.feature(:super_powers).inactive!
229
+ Togglefy.inactive!(:super_powers)
230
+ Togglefy.inactivate_feature(:super_powers)
126
231
  ```
127
232
 
128
233
  ### Managing Assignables <-> Features
129
234
  Now that we know how to create features, let's check how we can manage them.
130
235
 
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):
236
+ 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
237
 
133
238
  ```ruby
134
239
  user.has_feature?(:super_powers) # Checks if user has a single feature
@@ -139,7 +244,7 @@ user.clear_features # Clears all features from an user
139
244
 
140
245
  The assignable <-> feature relation is held by the `Togglefy::FeatureAssignment` table/model.
141
246
 
142
- But there's another way to manage assignables <-> features by using the `FeatureManager`. It's up to you to decide which one.
247
+ But there's another way to manage assignables <-> features by using the `FeatureAssignableManager`. It's up to you to decide which one.
143
248
 
144
249
  Here are the examples:
145
250
 
@@ -150,8 +255,59 @@ Togglefy.for(assignable).disable(:super_powers) # Disables/removes a feature fro
150
255
  Togglefy.for(assignable).clear # Clears all features from an assignable
151
256
  ```
152
257
 
258
+ You can also supercharge it by chaining methods, like:
259
+
260
+ ```ruby
261
+ # Instead of doing this:
262
+ Togglefy.for(assignable).disable(:alpha_access)
263
+ Togglefy.for(assignable).enable(:beta_access)
264
+
265
+ # You can go something like this:
266
+ Togglefy.for(assignable).disable(:alpha_access).enable(:beta_access)
267
+ ```
268
+
153
269
  This second method may look strange, but it's the default used by the gem and you will see that right now!
154
270
 
271
+ #### Mass Enable/Disable of Features to/from Assignables
272
+ You can mass enable/disable features to/from Assignables.
273
+
274
+ Doing that is simple! Let's assume that your assignable is an User model.
275
+
276
+ ```ruby
277
+ Togglefy.mass_for(User).bulk.enable(:super_powers) # To enable a specific feature to all users
278
+ Togglefy.mass_for(User).bulk.enable([:super_powers, :magic, ...]) # To enable two or more features to all users
279
+ Togglefy.mass_for(User).bulk.enable(:super_powers, percentage: 20) # To enable a feature to 20% of all users
280
+ Togglefy.mass_for(User).bulk.enable([:super_powers, :magic, ...], percentage: 50) # To enable two or more features to 50% of all users
281
+ ```
282
+
283
+ The same applies to the disable:
284
+
285
+ ```ruby
286
+ Togglefy.mass_for(User).bulk.disable(:super_powers) # To disable a specific feature to all users
287
+ Togglefy.mass_for(User).bulk.disable([:super_powers, :magic, ...]) # To disable two or more features to all users
288
+ Togglefy.mass_for(User).bulk.disable(:super_powers, percentage: 5) # To disable a feature to 5% of all users
289
+ Togglefy.mass_for(User).bulk.disable([:super_powers, :magic, ...], percentage: 75) # To disable two or more features to 75% of all users
290
+ ```
291
+
292
+ There are a few things to pay attention:
293
+ * Whenever you do a enable/disable, it will only query for valid assignables, so:
294
+ * If you do a enable, it will query all assignables that don't have the feature(s) enabled
295
+ * If you do a disable, it will query all assignables that do already have the feature(s) enabled
296
+ * You can also use filters for:
297
+ * `group || role`
298
+ * `environment || env`
299
+ * `tenant_id`
300
+ * You can check about filters aliases at [Aliases table](#aliases-table)
301
+
302
+ These will be applied to query features that match the identifiers + the filters sent.
303
+
304
+ So it would be something like:
305
+
306
+ ```ruby
307
+ Togglefy.mass_for(User).bulk.enable(:super_powers, group: :admin, percentage: 20)
308
+ Togglefy.mass_for(User).bulk.disable(:magic, group: :dev, env: :production, percentage: 75)
309
+ ```
310
+
155
311
  ### Querying Features
156
312
  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
313
 
@@ -178,12 +334,28 @@ Togglefy.for_filters(filters: {group: :admin, environment: :production})
178
334
 
179
335
  This will query me all `Togglefy::Feature`s that belongs to group admin and the production environment.
180
336
 
337
+ The `Togglefy.for_filters` can have the following filters:
338
+
339
+ ```ruby
340
+ filters: {
341
+ group:,
342
+ role:, # Acts as a group, explained in the Aliases section
343
+ environment:,
344
+ env:, # Acts as a group, explained in the Aliases section
345
+ tenant_id:,
346
+ status:,
347
+ identifier:
348
+ }
349
+ ```
350
+
351
+ The `Togglefy.for_filters` will only apply the filters sent that are `nil` or `!value.blank?`.
352
+
181
353
  You can send `nil` values too, like:
182
354
 
183
355
  ```ruby
184
356
  Togglefy.for_tenant(nil) # This will query me all Togglefy::Features with tenant_id nil
185
357
 
186
- Togglefy.for_filters(filters: {group: :admin, environment: :nil, tenant_id: nil})
358
+ Togglefy.for_filters(filters: {group: :admin, environment: nil, tenant_id: nil})
187
359
  ```
188
360
 
189
361
  There's also another way to filter for `nil` values:
@@ -210,13 +382,39 @@ Togglefy.with_status(:active)
210
382
  #### Finding a specific feature
211
383
  ```ruby
212
384
  Togglefy.feature(:super_powers)
385
+ Togglefy::Feature.identifier(:super_powers)
213
386
  Togglefy::Feature.find_by(identifier: :super_powers)
214
387
  ```
215
388
 
216
- #### Finding a specific feature just to destroy it because you're mean 😈
389
+ #### Finding multiple features
217
390
  ```ruby
218
- Togglefy.destroy_feature(:super_powers)
219
- Togglefy::Feature.find_by(identifier: :super_powers).destroy
391
+ Togglefy.feature([:super_powers, :magic])
392
+ Togglefy::Feature.identifier([:super_powers, :magic])
393
+ Togglefy::Feature.where(identifier: [:super_powers, :magic])
394
+ ```
395
+
396
+ #### List all features of an Assignable
397
+ Let's pretend that your assignable is an User.
398
+
399
+ ```ruby
400
+ user = User.find(1)
401
+ user.features
402
+ ```
403
+
404
+ #### Check all features an Assignable have/doesn't have
405
+ 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.
406
+
407
+ ```ruby
408
+ User.with_features(1)
409
+ User.with_features([2, 3])
410
+ User.without_features(1)
411
+ User.without_features([2, 3])
412
+ ```
413
+
414
+ #### Querying all features
415
+ ```ruby
416
+ Togglefy.features(:super_powers)
417
+ Togglefy::Feature.all
220
418
  ```
221
419
 
222
420
  #### Querying all features enabled to a klass
@@ -225,7 +423,7 @@ Let's assume that you have two different assignables: User and Account.
225
423
  You want to list all features being used by assignables of User type:
226
424
 
227
425
  ```ruby
228
- Togglefy.for_type(User) # This is return all current FeatureAssignment with a User assignable
426
+ Togglefy.for_type(User) # This returns all current FeatureAssignment with a User assignable
229
427
  ```
230
428
 
231
429
  #### Aliases
@@ -248,17 +446,39 @@ Togglefy.for_filters(filters: {environment: :production})
248
446
  Togglefy.for_filters(filter: {env: :production})
249
447
  ```
250
448
 
449
+ ## Aliases table
450
+
451
+ Here's a table of all aliases available on Togglefy.
452
+
453
+ You can use either, as long as you respect the rules listed.
454
+
455
+ | Original | Alias | Rules |
456
+ | --------------------- | -------------------- | --------------------------------------------------- |
457
+ | `for_group` | `for_role` | Can't be used if doing a direct `Togglefy::Feature` |
458
+ | `without_group` | `without_role` | Can't be used if doing a direct `Togglefy::Feature` |
459
+ | `for_environment` | `for_env` | Can't be used if doing a direct `Togglefy::Feature` |
460
+ | `without_environment` | `without_env` | Can't be used if doing a direct `Togglefy::Feature` |
461
+ | `group` | `role` | Used inside methods that receives filters |
462
+ | `environment` | `env` | Used inside methods that receives filters |
463
+ | `create` | `create_feature` | None |
464
+ | `update` | `update_feature` | None |
465
+ | `toggle` | `toggle_feature` | None |
466
+ | `active!` | `activate_feature` | None |
467
+ | `deactive!` | `inactivate_feature` | None |
468
+ | `destroy` | `destroy_feature` | None |
469
+
470
+
251
471
  ## Development
252
472
 
253
473
  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
474
 
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).
475
+ To install this gem onto your local machine, run `bundle exec rake install`.
256
476
 
257
477
  ## Contributing
258
478
 
259
479
  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
480
 
261
- There's a PR Template. Its usa is highly encouraged.
481
+ There's a PR Template. Its use is highly encouraged.
262
482
 
263
483
  ## License
264
484
 
@@ -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
 
@@ -1,4 +1,8 @@
1
- class CreateTogglefyFeatureAssignments < ActiveRecord::Migration[8.0]
1
+ # frozen_string_literal: true
2
+
3
+ rails_version = "#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}"
4
+
5
+ class CreateTogglefyFeatureAssignments < ActiveRecord::Migration[rails_version]
2
6
  def change
3
7
  create_table :togglefy_feature_assignments do |t|
4
8
  t.references :feature, null: false, foreign_key: { to_table: :togglefy_features }
@@ -1,4 +1,8 @@
1
- class CreateTogglefyFeatures < ActiveRecord::Migration[8.0]
1
+ # frozen_string_literal: true
2
+
3
+ rails_version = "#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}"
4
+
5
+ class CreateTogglefyFeatures < ActiveRecord::Migration[rails_version]
2
6
  def change
3
7
  create_table :togglefy_features do |t|
4
8
  t.string :name, null: false
@@ -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.1"
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.1
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-18 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