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 +4 -4
- data/README.md +238 -18
- data/app/models/togglefy/feature.rb +2 -0
- data/lib/generators/togglefy/templates/create_feature_assignments.rb +5 -1
- data/lib/generators/togglefy/templates/create_features.rb +5 -1
- data/lib/togglefy/assignable.rb +66 -0
- data/lib/togglefy/errors/assignables_not_found.rb +7 -0
- data/lib/togglefy/errors/bulk_toggle_failed.rb +11 -0
- data/lib/togglefy/errors/dependency_missing.rb +7 -0
- data/lib/togglefy/errors/error.rb +3 -0
- data/lib/togglefy/errors/feature_not_found.rb +7 -0
- data/lib/togglefy/errors/invalid_feature_attribute.rb +7 -0
- data/lib/togglefy/exceptions.rb +5 -0
- data/lib/togglefy/feature_assignable_manager.rb +30 -0
- data/lib/togglefy/feature_manager.rb +26 -12
- data/lib/togglefy/feature_query.rb +23 -14
- data/lib/togglefy/featureable.rb +4 -34
- data/lib/togglefy/scoped_bulk_wrapper.rb +13 -0
- data/lib/togglefy/services/bulk_toggler.rb +108 -0
- data/lib/togglefy/version.rb +1 -1
- data/lib/togglefy.rb +54 -9
- data/togglefy.gemspec +1 -7
- metadata +15 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 48668b30d043e271706d2264cba4c2ff6917bd36bc11aed4e8aee24aa9431917
|
4
|
+
data.tar.gz: f1d0543de69758fb5ef769b5b509901d4b9d1ae263d5f362489c7e099b0bc840
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e9ef4ce6770dc88b80b9a4a27b3ce4b43cfa8484b4115f6c6e1451065721e248f0fd0464b881c6edfacfa57f93dae213e10157910b4fc44bc93aa06f4aa3a724
|
7
|
+
data.tar.gz: 8181535e9663feb971707ac2d6806020c13e15745fc647508da0d0f578f57cb298fdd98927b90e525837c37b7f7ec5249d1eaab0eb5be580dd068d39e7a19f23
|
data/README.md
CHANGED
@@ -1,5 +1,8 @@
|
|
1
1
|
# Togglefy
|
2
2
|
|
3
|
+

|
4
|
+

|
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
|
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.
|
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::
|
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/
|
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
|
-
###
|
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
|
-
|
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::
|
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
|
-
|
125
|
-
feature.
|
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::
|
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 `
|
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:
|
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
|
389
|
+
#### Finding multiple features
|
217
390
|
```ruby
|
218
|
-
Togglefy.
|
219
|
-
Togglefy::Feature.
|
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
|
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`.
|
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
|
481
|
+
There's a PR Template. Its use is highly encouraged.
|
262
482
|
|
263
483
|
## License
|
264
484
|
|
@@ -1,4 +1,8 @@
|
|
1
|
-
|
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
|
-
|
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,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(
|
4
|
-
@
|
3
|
+
def initialize(identifier = nil)
|
4
|
+
@identifier = identifier unless identifier.nil?
|
5
5
|
end
|
6
6
|
|
7
|
-
def
|
8
|
-
|
7
|
+
def create(**params)
|
8
|
+
Togglefy::Feature.create!(**params)
|
9
9
|
end
|
10
10
|
|
11
|
-
def
|
12
|
-
|
11
|
+
def update(**params)
|
12
|
+
feature.update!(**params)
|
13
13
|
end
|
14
14
|
|
15
|
-
def
|
16
|
-
|
15
|
+
def destroy
|
16
|
+
feature.destroy
|
17
17
|
end
|
18
18
|
|
19
|
-
def
|
20
|
-
|
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 :
|
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
|
4
|
-
Togglefy::Feature.
|
3
|
+
def features
|
4
|
+
Togglefy::Feature.all
|
5
5
|
end
|
6
6
|
|
7
|
-
def
|
8
|
-
|
9
|
-
|
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
|
-
.
|
47
|
-
.
|
48
|
-
.
|
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
|
52
|
-
|
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
|
data/lib/togglefy/featureable.rb
CHANGED
@@ -1,36 +1,6 @@
|
|
1
|
-
|
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
|
-
|
25
|
-
|
26
|
-
|
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,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
|
data/lib/togglefy/version.rb
CHANGED
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
|
-
|
13
|
-
|
16
|
+
# FeatureQuery
|
17
|
+
def self.features
|
18
|
+
FeatureQuery.new.features
|
14
19
|
end
|
15
20
|
|
16
|
-
def self.
|
17
|
-
FeatureQuery.new.
|
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
|
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.
|
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-
|
10
|
+
date: 2025-04-18 00:00:00.000000000 Z
|
11
11
|
dependencies: []
|
12
|
-
description: Togglefy is a feature management
|
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
|