magick-feature-flags 0.9.34 → 0.9.37

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: 72089ec7fc15f5c7f854f03caf17896d4810f99d1067be9fadc17c9c5569b341
4
- data.tar.gz: e928f66dd35ada0bb734c424178ba3c6db78f934b49599c21296506da697c4c2
3
+ metadata.gz: 8fc6c9effed1052fbf1f929d206c9714917c1a5281234d517b4679cf9da44ded
4
+ data.tar.gz: cb345d7d484c471906a91b47ef63c3450e7613015ac6ca335c24bd73390f95f3
5
5
  SHA512:
6
- metadata.gz: 0241c50192a89be6f429eac0367538687d8bb5d5dcd897cb1e7804cca4ce452d8e975132b62a0e1bc193f380294df1f0a9b2923680559964944fb9046be8fd94
7
- data.tar.gz: dadadc9da7abcb0d031ec65c2ca016d9be0c4d914f1dd7240fbae12c614c8fc140e697a095e1fba744e0f691a6b2cacd3ab843c24ed479b4ecfdbd01c9ad04d6
6
+ metadata.gz: 3ad0164ef606ed0cf3112ee94013a084a45ae16a3fc9f8a3ed6647043be76aa367f868d38324dc7dcb5118e4263164902eedd7704846d90c90c993e09dba85d7
7
+ data.tar.gz: f4b37a363842a2b34019c92370f0446a565a1087c90e0fb467244103bc1213c3dfc57b97c8b2e7d1b5623329862f9af23f9e75d2c0d0641ffb2b323d2289ba0a
data/README.md CHANGED
@@ -45,18 +45,15 @@ This will create `config/initializers/magick.rb` with a basic configuration.
45
45
 
46
46
  ### ActiveRecord Adapter (Optional)
47
47
 
48
- If you want to use ActiveRecord as a persistent storage backend, generate the migration:
48
+ If you want to use ActiveRecord as a persistent storage backend, you **must** generate and run the migration:
49
49
 
50
50
  ```bash
51
51
  rails generate magick:active_record
52
- ```
53
-
54
- This will create a migration file that creates the `magick_features` table. Then run:
55
-
56
- ```bash
57
52
  rails db:migrate
58
53
  ```
59
54
 
55
+ This will create a migration file that creates the `magick_features` table. **The adapter will not auto-create the table** - you must run migrations.
56
+
60
57
  **Note:** The ActiveRecord adapter is optional and only needed if you want database-backed feature flags. The gem works perfectly fine with just the memory adapter or Redis adapter.
61
58
 
62
59
  ## Configuration
@@ -137,7 +134,8 @@ end
137
134
  Magick.register_feature(:new_dashboard,
138
135
  type: :boolean,
139
136
  default_value: false,
140
- description: "New dashboard UI"
137
+ description: "New dashboard UI",
138
+ group: "UI" # Optional: group features for organization
141
139
  )
142
140
 
143
141
  # Register a string feature
@@ -460,7 +458,7 @@ The ActiveRecord adapter provides database-backed persistent storage for feature
460
458
 
461
459
  **Setup:**
462
460
 
463
- 1. Generate the migration:
461
+ 1. **Generate and run the migration** (required):
464
462
  ```bash
465
463
  rails generate magick:active_record
466
464
  rails db:migrate
@@ -469,8 +467,11 @@ The ActiveRecord adapter provides database-backed persistent storage for feature
469
467
  **With UUID primary keys:**
470
468
  ```bash
471
469
  rails generate magick:active_record --uuid
470
+ rails db:migrate
472
471
  ```
473
472
 
473
+ **Important:** The adapter will **not** auto-create the table. You must run migrations before using the ActiveRecord adapter. If the table doesn't exist, the adapter will raise a clear error with instructions.
474
+
474
475
  2. Configure in `config/initializers/magick.rb`:
475
476
  ```ruby
476
477
  Magick.configure do
@@ -480,8 +481,6 @@ The ActiveRecord adapter provides database-backed persistent storage for feature
480
481
  end
481
482
  ```
482
483
 
483
- The adapter automatically creates the `magick_features` table if it doesn't exist, but using the generator is recommended for production applications.
484
-
485
484
  **PostgreSQL Support:**
486
485
 
487
486
  The generator automatically detects PostgreSQL and uses `jsonb` for the `data` column, providing:
@@ -566,6 +565,33 @@ Once mounted, visit `/magick` in your browser to access the Admin UI.
566
565
  - **Visual Display**: See all active targeting rules with badges
567
566
  - **Edit Features**: Update feature values (boolean, string, number) directly from the UI
568
567
  - **Statistics**: View performance metrics and usage statistics for each feature
568
+ - **Feature Grouping**: Organize features into groups for easier management and filtering
569
+ - **Filtering**: Filter features by group, name, or description
570
+
571
+ **Feature Grouping:**
572
+
573
+ Features can be organized into groups for easier management and filtering:
574
+
575
+ 1. **Setting Groups**:
576
+ - Set a group when registering a feature in code:
577
+ ```ruby
578
+ Magick.register_feature(:new_payment_flow,
579
+ type: :boolean,
580
+ default_value: false,
581
+ group: 'Payment',
582
+ description: "New payment processing flow"
583
+ )
584
+ ```
585
+ - Or set/update groups via the Admin UI when editing a feature
586
+
587
+ 2. **Filtering by Group**:
588
+ - Use the group dropdown in the Admin UI to filter features by group
589
+ - Combine group filtering with search to find specific features quickly
590
+
591
+ 3. **Benefits**:
592
+ - Organize features by functional area (e.g., "Authentication", "Payment", "UI")
593
+ - Quickly find related features
594
+ - Better organization for large feature flag sets
569
595
 
570
596
  **Targeting Management:**
571
597
 
@@ -26,6 +26,24 @@ module Magick
26
26
 
27
27
  def index
28
28
  @features = Magick.features.values
29
+
30
+ # Filter by group if provided
31
+ if params[:group].present?
32
+ @features = @features.select { |f| f.group == params[:group] }
33
+ end
34
+
35
+ # Filter by search query (name or description)
36
+ if params[:search].present?
37
+ search_term = params[:search].downcase
38
+ @features = @features.select do |f|
39
+ f.name.downcase.include?(search_term) ||
40
+ (f.display_name && f.display_name.downcase.include?(search_term)) ||
41
+ (f.description && f.description.downcase.include?(search_term))
42
+ end
43
+ end
44
+
45
+ # Get all available groups for filter dropdown
46
+ @available_groups = Magick.features.values.map(&:group).compact.uniq.sort
29
47
  end
30
48
 
31
49
  def show
@@ -35,6 +53,11 @@ module Magick
35
53
  end
36
54
 
37
55
  def update
56
+ # Update group if provided
57
+ if params.key?(:group)
58
+ @feature.set_group(params[:group])
59
+ end
60
+
38
61
  if @feature.type == :boolean
39
62
  # For boolean features, checkbox sends 'true' when checked, nothing when unchecked
40
63
  # Rails form helpers handle this - if checkbox is unchecked, params[:value] will be nil
@@ -18,6 +18,12 @@
18
18
  <input type="text" value="<%= @feature.type.to_s.capitalize %>" disabled>
19
19
  </div>
20
20
 
21
+ <div class="form-group">
22
+ <label>Group</label>
23
+ <%= text_field_tag 'group', @feature.group, placeholder: 'e.g., Authentication, Payment, UI', class: 'form-control' %>
24
+ <small style="color: #999;">Optional: Group features together for easier organization and filtering</small>
25
+ </div>
26
+
21
27
  <div class="form-group">
22
28
  <label>Current Value</label>
23
29
  <% if @feature.type == :boolean %>
@@ -6,6 +6,26 @@
6
6
  </div>
7
7
  </div>
8
8
 
9
+ <!-- Filtering UI -->
10
+ <div style="padding: 16px; border-bottom: 1px solid #e0e0e0; background: #f9f9f9;">
11
+ <%= form_with url: magick_admin_ui.features_path, method: :get, local: true, style: "display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end;" do |f| %>
12
+ <div style="flex: 1; min-width: 200px;">
13
+ <label for="search" style="display: block; margin-bottom: 4px; font-weight: 500;">Search</label>
14
+ <%= text_field_tag :search, params[:search], placeholder: "Search by name or description...", class: 'form-control', style: "width: 100%;" %>
15
+ </div>
16
+ <div style="flex: 0 0 180px;">
17
+ <label for="group" style="display: block; margin-bottom: 4px; font-weight: 500;">Group</label>
18
+ <%= select_tag :group, options_for_select([['All Groups', '']] + @available_groups.map { |g| [g, g] }, params[:group]), class: 'form-control', style: "width: 100%;" %>
19
+ </div>
20
+ <div style="flex: 0 0 auto;">
21
+ <%= submit_tag 'Filter', class: 'btn btn-primary btn-sm', style: "height: 38px;" %>
22
+ <% if params[:search].present? || params[:group].present? %>
23
+ <%= link_to 'Clear', magick_admin_ui.features_path, class: 'btn btn-secondary btn-sm', style: "height: 38px;" %>
24
+ <% end %>
25
+ </div>
26
+ <% end %>
27
+ </div>
28
+
9
29
  <% if @features.empty? %>
10
30
  <div class="empty-state">
11
31
  <h3>No features found</h3>
@@ -16,6 +36,7 @@
16
36
  <thead>
17
37
  <tr>
18
38
  <th>Name</th>
39
+ <th>Group</th>
19
40
  <th>Type</th>
20
41
  <th>Status</th>
21
42
  <th>Value</th>
@@ -31,6 +52,13 @@
31
52
  <br>
32
53
  <small style="color: #999;"><%= feature.name %></small>
33
54
  </td>
55
+ <td data-label="Group">
56
+ <% if feature.group.present? %>
57
+ <span class="badge badge-info"><%= feature.group %></span>
58
+ <% else %>
59
+ <em style="color: #999;">—</em>
60
+ <% end %>
61
+ </td>
34
62
  <td data-label="Type">
35
63
  <span class="badge badge-info">
36
64
  <%= feature.type.to_s.capitalize %>
@@ -18,6 +18,14 @@
18
18
  <span class="badge badge-info"><%= @feature.type.to_s.capitalize %></span>
19
19
  </div>
20
20
  </div>
21
+ <% if @feature.group.present? %>
22
+ <div class="detail-item">
23
+ <div class="detail-label">Group</div>
24
+ <div class="detail-value">
25
+ <span class="badge badge-info"><%= @feature.group %></span>
26
+ </div>
27
+ </div>
28
+ <% end %>
21
29
  <div class="detail-item">
22
30
  <div class="detail-label">Status</div>
23
31
  <div class="detail-value">
@@ -3,18 +3,17 @@
3
3
  module Magick
4
4
  module Adapters
5
5
  class ActiveRecord < Base
6
- @table_created_mutex = Mutex.new
7
- @table_created = false
8
-
9
6
  def initialize(model_class: nil)
10
7
  @model_class = model_class || default_model_class
11
- ensure_table_exists
8
+ # Verify table exists - raise clear error if it doesn't
9
+ unless @model_class.table_exists?
10
+ raise AdapterError, "Table 'magick_features' does not exist. Please run: rails generate magick:active_record && rails db:migrate"
11
+ end
12
12
  rescue StandardError => e
13
13
  raise AdapterError, "Failed to initialize ActiveRecord adapter: #{e.message}"
14
14
  end
15
15
 
16
16
  def get(feature_name, key)
17
- ensure_table_exists unless @model_class.table_exists?
18
17
  feature_name_str = feature_name.to_s
19
18
  record = @model_class.find_by(feature_name: feature_name_str)
20
19
  return nil unless record
@@ -24,16 +23,10 @@ module Magick
24
23
  value = data.is_a?(Hash) ? data[key.to_s] : nil
25
24
  deserialize_value(value)
26
25
  rescue StandardError => e
27
- # If table doesn't exist, try to create it and retry once
28
- if e.message.include?('no such table') || e.message.include?("doesn't exist")
29
- ensure_table_exists
30
- retry
31
- end
32
26
  raise AdapterError, "Failed to get from ActiveRecord: #{e.message}"
33
27
  end
34
28
 
35
29
  def set(feature_name, key, value)
36
- ensure_table_exists unless @model_class.table_exists?
37
30
  feature_name_str = feature_name.to_s
38
31
  retries = 5
39
32
  begin
@@ -48,54 +41,31 @@ module Magick
48
41
  record.save!
49
42
  rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::ConnectionTimeoutError => e
50
43
  # SQLite busy/locked errors - retry with exponential backoff
51
- if (e.message.include?('database is locked') || e.message.include?('busy') || e.message.include?('timeout') || e.message.include?('no such table')) && retries > 0
44
+ if (e.message.include?('database is locked') || e.message.include?('busy') || e.message.include?('timeout')) && retries > 0
52
45
  retries -= 1
53
- # If it's a "no such table" error, ensure table exists
54
- if e.message.include?('no such table')
55
- ensure_table_exists
56
- end
57
46
  sleep(0.01 * (6 - retries)) # Exponential backoff: 0.01, 0.02, 0.03, 0.04, 0.05
58
47
  retry
59
48
  end
60
49
  raise AdapterError, "Failed to set in ActiveRecord: #{e.message}"
61
50
  rescue StandardError => e
62
- # If table doesn't exist, try to create it and retry once
63
- if (e.message.include?('no such table') || e.message.include?("doesn't exist")) && retries > 0
64
- retries -= 1
65
- ensure_table_exists
66
- sleep(0.01)
67
- retry
68
- end
69
51
  raise AdapterError, "Failed to set in ActiveRecord: #{e.message}"
70
52
  end
71
53
  end
72
54
 
73
55
  def delete(feature_name)
74
- ensure_table_exists unless @model_class.table_exists?
75
56
  feature_name_str = feature_name.to_s
76
57
  retries = 5
77
58
  begin
78
59
  @model_class.where(feature_name: feature_name_str).destroy_all
79
60
  rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::ConnectionTimeoutError => e
80
61
  # SQLite busy/locked errors - retry with exponential backoff
81
- if (e.message.include?('database is locked') || e.message.include?('busy') || e.message.include?('timeout') || e.message.include?('no such table')) && retries > 0
62
+ if (e.message.include?('database is locked') || e.message.include?('busy') || e.message.include?('timeout')) && retries > 0
82
63
  retries -= 1
83
- # If it's a "no such table" error, ensure table exists
84
- if e.message.include?('no such table')
85
- ensure_table_exists
86
- end
87
64
  sleep(0.01 * (6 - retries)) # Exponential backoff: 0.01, 0.02, 0.03, 0.04, 0.05
88
65
  retry
89
66
  end
90
67
  raise AdapterError, "Failed to delete from ActiveRecord: #{e.message}"
91
68
  rescue StandardError => e
92
- # If table doesn't exist, try to create it and retry once
93
- if (e.message.include?('no such table') || e.message.include?("doesn't exist")) && retries > 0
94
- retries -= 1
95
- ensure_table_exists
96
- sleep(0.01)
97
- retry
98
- end
99
69
  raise AdapterError, "Failed to delete from ActiveRecord: #{e.message}"
100
70
  end
101
71
  end
@@ -148,66 +118,6 @@ module Magick
148
118
  end)
149
119
  end
150
120
 
151
- def ensure_table_exists
152
- return if @model_class.table_exists?
153
-
154
- # Use a non-blocking mutex to prevent deadlocks
155
- mutex = self.class.instance_variable_get(:@table_created_mutex)
156
- if mutex.try_lock
157
- begin
158
- # Double-check after acquiring lock
159
- return if @model_class.table_exists?
160
-
161
- create_table
162
- self.class.instance_variable_set(:@table_created, true)
163
- ensure
164
- mutex.unlock
165
- end
166
- else
167
- # Another thread is creating the table, wait for it to complete
168
- # Use a longer timeout with exponential backoff to avoid hanging
169
- 20.times do |i|
170
- sleep(0.01 * (i + 1)) # Exponential backoff: 0.01, 0.02, 0.03, ...
171
- return if @model_class.table_exists?
172
- end
173
- # If we still don't have the table, try one more time
174
- unless @model_class.table_exists?
175
- # Last attempt: try to acquire lock and create
176
- if mutex.try_lock
177
- begin
178
- return if @model_class.table_exists?
179
- create_table
180
- self.class.instance_variable_set(:@table_created, true)
181
- ensure
182
- mutex.unlock
183
- end
184
- end
185
- end
186
- end
187
- rescue StandardError => e
188
- # Don't raise if table exists now (might have been created by another thread)
189
- return if @model_class.table_exists?
190
- raise e
191
- end
192
-
193
- def create_table
194
- connection = @model_class.connection
195
- return if connection.table_exists?('magick_features')
196
-
197
- connection.create_table :magick_features do |t|
198
- t.string :feature_name, null: false, index: { unique: true }
199
- t.text :data
200
- t.timestamps
201
- end
202
- rescue StandardError => e
203
- # Table might already exist or migration might be needed
204
- # Check if table exists now (might have been created by another thread)
205
- return if connection.table_exists?('magick_features')
206
-
207
- warn "Magick: Could not create magick_features table: #{e.message}" if defined?(Rails) && Rails.env.development?
208
- raise e if defined?(Rails) && Rails.env.test?
209
- end
210
-
211
121
  def serialize_value(value)
212
122
  # For ActiveRecord 8.1+ with attribute :json, we can store booleans as-is
213
123
  # For older versions with serialize, we convert to strings
@@ -8,7 +8,7 @@ module Magick
8
8
  VALID_TYPES = %i[boolean string number].freeze
9
9
  VALID_STATUSES = %i[active inactive deprecated].freeze
10
10
 
11
- attr_reader :name, :type, :status, :default_value, :description, :display_name, :adapter_registry
11
+ attr_reader :name, :type, :status, :default_value, :description, :display_name, :group, :adapter_registry
12
12
 
13
13
  def initialize(name, adapter_registry, **options)
14
14
  @name = name.to_s
@@ -18,6 +18,7 @@ module Magick
18
18
  @default_value = options.fetch(:default_value, default_for_type)
19
19
  @description = options[:description]
20
20
  @display_name = options[:name] || options[:display_name]
21
+ @group = options[:group]
21
22
  @targeting = {}
22
23
  @dependencies = options[:dependencies] ? Array(options[:dependencies]) : []
23
24
  @stored_value_initialized = false # Track if @stored_value has been explicitly set
@@ -405,6 +406,7 @@ module Magick
405
406
  adapter_registry.set(name, 'default_value', default_value)
406
407
  adapter_registry.set(name, 'description', description) if description
407
408
  adapter_registry.set(name, 'display_name', display_name) if display_name
409
+ adapter_registry.set(name, 'group', group) if group
408
410
  @stored_value = value
409
411
  @stored_value_initialized = true # Mark as initialized
410
412
 
@@ -516,6 +518,24 @@ module Magick
516
518
  true
517
519
  end
518
520
 
521
+ def set_group(group_name)
522
+ if group_name.nil? || group_name.to_s.strip.empty?
523
+ @group = nil
524
+ # Clear group from adapter by setting to empty string (adapters handle this)
525
+ adapter_registry.set(name, 'group', nil)
526
+ else
527
+ @group = group_name.to_s.strip
528
+ adapter_registry.set(name, 'group', @group)
529
+ end
530
+
531
+ # Update registered feature instance if it exists
532
+ if Magick.features.key?(name)
533
+ Magick.features[name].instance_variable_set(:@group, @group)
534
+ end
535
+
536
+ true
537
+ end
538
+
519
539
  def delete
520
540
  adapter_registry.delete(name)
521
541
  @stored_value = nil
@@ -541,6 +561,7 @@ module Magick
541
561
  registered.instance_variable_set(:@status, @status)
542
562
  registered.instance_variable_set(:@description, @description)
543
563
  registered.instance_variable_set(:@display_name, @display_name)
564
+ registered.instance_variable_set(:@group, @group)
544
565
  registered.instance_variable_set(:@targeting, @targeting.dup)
545
566
  registered.instance_variable_set(:@_targeting_empty, @_targeting_empty)
546
567
  registered.instance_variable_set(:@_perf_metrics_enabled, @_perf_metrics_enabled)
@@ -619,6 +640,10 @@ module Magick
619
640
  @display_name = display_name_value if display_name_value
620
641
  end
621
642
 
643
+ # Load group from adapter (can be set via DSL or Admin UI)
644
+ group_value = adapter_registry.get(name, 'group')
645
+ @group = group_value if group_value
646
+
622
647
  targeting_value = adapter_registry.get(name, 'targeting')
623
648
  if targeting_value.is_a?(Hash)
624
649
  # Normalize keys to symbols and handle nested structures
@@ -636,9 +661,8 @@ module Magick
636
661
  # The features.rb file is the source of truth for metadata
637
662
  # This ensures metadata is always up-to-date even if feature already exists
638
663
  adapter_registry.set(name, 'description', @description) if @description
639
- return unless @display_name
640
-
641
- adapter_registry.set(name, 'display_name', @display_name)
664
+ adapter_registry.set(name, 'display_name', @display_name) if @display_name
665
+ adapter_registry.set(name, 'group', @group) if @group
642
666
  end
643
667
 
644
668
  def load_value_from_adapter
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '0.9.34'
4
+ VERSION = '0.9.37'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: magick-feature-flags
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.34
4
+ version: 0.9.37
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '2.22'
46
+ version: '3.8'
47
47
  type: :development
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '2.22'
53
+ version: '3.8'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: activerecord
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -77,14 +77,14 @@ dependencies:
77
77
  requirements:
78
78
  - - "~>"
79
79
  - !ruby/object:Gem::Version
80
- version: '1.6'
80
+ version: '2.0'
81
81
  type: :development
82
82
  prerelease: false
83
83
  version_requirements: !ruby/object:Gem::Requirement
84
84
  requirements:
85
85
  - - "~>"
86
86
  - !ruby/object:Gem::Version
87
- version: '1.6'
87
+ version: '2.0'
88
88
  description: Magick is a better free version of Flipper feature-toggle gem. It is
89
89
  absolutely performant and memory efficient (by my opinion).
90
90
  email:
@@ -160,7 +160,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
160
160
  requirements:
161
161
  - - ">="
162
162
  - !ruby/object:Gem::Version
163
- version: 3.0.0
163
+ version: 3.2.0
164
164
  required_rubygems_version: !ruby/object:Gem::Requirement
165
165
  requirements:
166
166
  - - ">="