magick-feature-flags 0.7.4 → 0.8.2

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: b3180a040250aa0ad2324ae93e18fa71104d95c448149dfaacdfef3e6e27e811
4
- data.tar.gz: 1674a42ae320c0693db393a86fe78c4c5d83ad374277269539bd1db361d151c5
3
+ metadata.gz: a53f3c93e974822f126ed2148d9fb61adf7de3a374ba26acbd834a2a916545a0
4
+ data.tar.gz: 31dbbacc15565a496e65113364d3eaf5a6a5385251f3654a28ea1c5eb9e9b014
5
5
  SHA512:
6
- metadata.gz: 3306b269e2ba2217cf4a7eaa08844243a1fbb0b4a2e0877fe4a5935c6f3ccd95337bbfae42eda44f37dd5a2cfa5efb1359814d2698100ae80b86ce1b6e4253ac
7
- data.tar.gz: da612304443324312b62c7ed5c0ad113eda11507d6aa436799ce687b689c1085c0a93d3948fc5f1b9ba31e3f7f179a5d1e18caec6c5cecf4f07ff84c4772b141
6
+ metadata.gz: 9b39439b771eb158d39496758362675db1134ea160311b8af9d51b59e1b40c7440ee425ee7d8f6e28bd02dc3da6498ec24d79744cd5cb7b63f54e13e86fa8cf4
7
+ data.tar.gz: 0bc2519decc3164d6b3dddd6080bf79c00b9a1ee2473d95b25550edbea8ca8c433912b8655d72c86b993762526781377df27219f1957585990b59942e3597bb8
data/README.md CHANGED
@@ -166,14 +166,70 @@ feature.enable_for_ip_addresses('192.168.1.0/24', '10.0.0.1')
166
166
  feature.enable_for_custom_attribute(:subscription_tier, ['premium', 'enterprise'])
167
167
  ```
168
168
 
169
+ ### Checking Feature Enablement with Objects
170
+
171
+ You can check if a feature is enabled for an object (like a User model) and its fields:
172
+
173
+ ```ruby
174
+ # Using enabled_for? with an object
175
+ user = User.find(123)
176
+ if Magick.enabled_for?(:premium_features, user)
177
+ # Feature is enabled for this user
178
+ end
179
+
180
+ # Or using the feature directly
181
+ feature = Magick[:premium_features]
182
+ if feature.enabled_for?(user)
183
+ # Feature is enabled for this user
184
+ end
185
+
186
+ # With additional context
187
+ if Magick.enabled_for?(:premium_features, user, ip_address: request.remote_ip)
188
+ # Feature is enabled for this user and IP
189
+ end
190
+
191
+ # Works with ActiveRecord objects, hashes, or simple IDs
192
+ Magick.enabled_for?(:feature, user) # ActiveRecord object
193
+ Magick.enabled_for?(:feature, { id: 123, role: 'admin' }) # Hash
194
+ Magick.enabled_for?(:feature, 123) # Simple ID
195
+ ```
196
+
197
+ The `enabled_for?` method automatically extracts:
198
+ - `user_id` from `id` or `user_id` attribute
199
+ - `group` from `group` attribute
200
+ - `role` from `role` attribute
201
+ - `ip_address` from `ip_address` attribute
202
+ - All other attributes for custom attribute matching
203
+
204
+ ### Return Values
205
+
206
+ All enable/disable methods now return `true` to indicate success:
207
+
208
+ ```ruby
209
+ # All these methods return true on success
210
+ result = feature.enable # => true
211
+ result = feature.disable # => true
212
+ result = feature.enable_for_user(123) # => true
213
+ result = feature.enable_for_group('beta') # => true
214
+ result = feature.enable_percentage_of_users(25) # => true
215
+ result = feature.set_value(true) # => true
216
+ ```
217
+
169
218
  ### DSL Configuration
170
219
 
171
220
  Create `config/features.rb`:
172
221
 
173
222
  ```ruby
174
223
  # Boolean features
175
- boolean_feature :new_dashboard, default: false, description: "New dashboard UI"
176
- boolean_feature :dark_mode, default: false, description: "Dark mode theme"
224
+ boolean_feature :new_dashboard,
225
+ default: false,
226
+ name: "New Dashboard",
227
+ description: "New dashboard UI"
228
+
229
+ boolean_feature :dark_mode,
230
+ default: false,
231
+ name: "Dark Mode",
232
+ description: "Dark mode theme"
177
233
 
178
234
  # String features
179
235
  string_feature :api_version, default: "v1", description: "API version"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # Magick Configuration
2
4
  # Generated by: rails generate magick:install
3
5
  #
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'thread'
4
-
5
3
  module Magick
6
4
  module Adapters
7
5
  class Memory < Base
@@ -3,7 +3,7 @@
3
3
  module Magick
4
4
  module Adapters
5
5
  class Registry
6
- CACHE_INVALIDATION_CHANNEL = 'magick:cache:invalidate'.freeze
6
+ CACHE_INVALIDATION_CHANNEL = 'magick:cache:invalidate'
7
7
 
8
8
  def initialize(memory_adapter, redis_adapter = nil, circuit_breaker: nil, async: false)
9
9
  @memory_adapter = memory_adapter
@@ -43,36 +43,36 @@ module Magick
43
43
  memory_adapter.set(feature_name, key, value)
44
44
 
45
45
  # Update Redis if available
46
- if redis_adapter
47
- update_redis = proc do
48
- circuit_breaker.call do
49
- redis_adapter.set(feature_name, key, value)
50
- # Publish cache invalidation message to notify other processes
51
- publish_cache_invalidation(feature_name)
52
- end
53
- rescue AdapterError => e
54
- # Log error but don't fail - memory is updated
55
- warn "Failed to update Redis: #{e.message}" if defined?(Rails) && Rails.env.development?
56
- end
46
+ return unless redis_adapter
57
47
 
58
- if @async && defined?(Thread)
59
- Thread.new { update_redis.call }
60
- else
61
- update_redis.call
48
+ update_redis = proc do
49
+ circuit_breaker.call do
50
+ redis_adapter.set(feature_name, key, value)
51
+ # Publish cache invalidation message to notify other processes
52
+ publish_cache_invalidation(feature_name)
62
53
  end
54
+ rescue AdapterError => e
55
+ # Log error but don't fail - memory is updated
56
+ warn "Failed to update Redis: #{e.message}" if defined?(Rails) && Rails.env.development?
57
+ end
58
+
59
+ if @async && defined?(Thread)
60
+ Thread.new { update_redis.call }
61
+ else
62
+ update_redis.call
63
63
  end
64
64
  end
65
65
 
66
66
  def delete(feature_name)
67
67
  memory_adapter.delete(feature_name)
68
- if redis_adapter
69
- begin
70
- redis_adapter.delete(feature_name)
71
- # Publish cache invalidation message
72
- publish_cache_invalidation(feature_name)
73
- rescue AdapterError
74
- # Continue even if Redis fails
75
- end
68
+ return unless redis_adapter
69
+
70
+ begin
71
+ redis_adapter.delete(feature_name)
72
+ # Publish cache invalidation message
73
+ publish_cache_invalidation(feature_name)
74
+ rescue AdapterError
75
+ # Continue even if Redis fails
76
76
  end
77
77
  end
78
78
 
@@ -108,23 +108,21 @@ module Magick
108
108
  return unless redis_adapter && defined?(Thread)
109
109
 
110
110
  @subscriber_thread = Thread.new do
111
- begin
112
- redis_client = redis_adapter.instance_variable_get(:@redis)
113
- return unless redis_client
114
-
115
- @subscriber = redis_client.dup
116
- @subscriber.subscribe(CACHE_INVALIDATION_CHANNEL) do |on|
117
- on.message do |_channel, feature_name|
118
- # Invalidate memory cache for this feature
119
- memory_adapter.delete(feature_name)
120
- end
111
+ redis_client = redis_adapter.instance_variable_get(:@redis)
112
+ return unless redis_client
113
+
114
+ @subscriber = redis_client.dup
115
+ @subscriber.subscribe(CACHE_INVALIDATION_CHANNEL) do |on|
116
+ on.message do |_channel, feature_name|
117
+ # Invalidate memory cache for this feature
118
+ memory_adapter.delete(feature_name)
121
119
  end
122
- rescue StandardError => e
123
- # If subscription fails, log and retry after a delay
124
- warn "Cache invalidation subscriber error: #{e.message}" if defined?(Rails) && Rails.env.development?
125
- sleep 5
126
- retry
127
120
  end
121
+ rescue StandardError => e
122
+ # If subscription fails, log and retry after a delay
123
+ warn "Cache invalidation subscriber error: #{e.message}" if defined?(Rails) && Rails.env.development?
124
+ sleep 5
125
+ retry
128
126
  end
129
127
  @subscriber_thread.abort_on_exception = false
130
128
  end
data/lib/magick/config.rb CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  module Magick
4
4
  class Config
5
- attr_accessor :adapter_registry, :performance_metrics, :audit_log, :versioning
6
- attr_accessor :warn_on_deprecated, :async_updates, :memory_ttl, :circuit_breaker_threshold
7
- attr_accessor :circuit_breaker_timeout, :redis_url, :redis_namespace, :environment
5
+ attr_accessor :adapter_registry, :performance_metrics, :audit_log, :versioning, :warn_on_deprecated,
6
+ :async_updates, :memory_ttl, :circuit_breaker_threshold, :circuit_breaker_timeout, :redis_url, :redis_namespace, :environment
8
7
 
9
8
  def initialize
10
9
  @warn_on_deprecated = false
@@ -45,28 +44,26 @@ module Magick
45
44
  configure_redis_adapter(url: url, namespace: namespace, **options)
46
45
  end
47
46
 
48
- def performance_metrics(enabled: true, **options)
49
- if enabled
50
- @performance_metrics = PerformanceMetrics.new
51
- else
52
- @performance_metrics = nil
47
+ def performance_metrics(enabled: true, redis_tracking: nil, batch_size: 100, flush_interval: 60, **_options)
48
+ return unless enabled
49
+
50
+ @performance_metrics = PerformanceMetrics.new(batch_size: batch_size, flush_interval: flush_interval)
51
+ # Enable Redis tracking if Redis adapter is configured (or explicitly set)
52
+ if redis_tracking.nil?
53
+ # Auto-enable if Redis adapter exists
54
+ redis_tracking = !@redis_url.nil? || configure_redis_adapter != nil
53
55
  end
56
+ @performance_metrics.enable_redis_tracking(enable: redis_tracking) if @performance_metrics
54
57
  end
55
58
 
56
59
  def audit_log(enabled: true, adapter: nil)
57
- if enabled
58
- @audit_log = adapter ? AuditLog.new(adapter) : AuditLog.new
59
- else
60
- @audit_log = nil
61
- end
60
+ @audit_log = if enabled
61
+ adapter ? AuditLog.new(adapter) : AuditLog.new
62
+ end
62
63
  end
63
64
 
64
65
  def versioning(enabled: true)
65
- if enabled
66
- @versioning = Versioning.new(adapter_registry || default_adapter_registry)
67
- else
68
- @versioning = nil
69
- end
66
+ @versioning = (Versioning.new(adapter_registry || default_adapter_registry) if enabled)
70
67
  end
71
68
 
72
69
  def circuit_breaker(threshold: nil, timeout: nil)
@@ -97,6 +94,11 @@ module Magick
97
94
  Magick.audit_log = audit_log if audit_log
98
95
  Magick.versioning = versioning if versioning
99
96
  Magick.warn_on_deprecated = warn_on_deprecated
97
+
98
+ # Enable Redis tracking for performance metrics if Redis adapter is configured
99
+ if Magick.performance_metrics && adapter_registry.is_a?(Adapters::Registry) && adapter_registry.redis_adapter
100
+ Magick.performance_metrics.enable_redis_tracking(enable: true)
101
+ end
100
102
  end
101
103
 
102
104
  private
data/lib/magick/dsl.rb CHANGED
@@ -70,6 +70,15 @@ module Magick
70
70
  def disable_feature(feature_name, user_id: nil)
71
71
  Magick[feature_name].disable(user_id: user_id)
72
72
  end
73
+
74
+ # Check enablement for an object
75
+ def enabled_for?(feature_name, object, **additional_context)
76
+ Magick.enabled_for?(feature_name, object, **additional_context)
77
+ end
78
+
79
+ def disabled_for?(feature_name, object, **additional_context)
80
+ Magick.disabled_for?(feature_name, object, **additional_context)
81
+ end
73
82
  end
74
83
  end
75
84
 
@@ -5,7 +5,7 @@ require 'json'
5
5
  module Magick
6
6
  class ExportImport
7
7
  def self.export(features_hash)
8
- result = features_hash.map do |name, feature|
8
+ result = features_hash.map do |_name, feature|
9
9
  feature.to_h
10
10
  end
11
11
 
@@ -45,7 +45,9 @@ module Magick
45
45
  description: feature_data['description'] || feature_data[:description]
46
46
  )
47
47
 
48
- feature.set_value(feature_data['value'] || feature_data[:value]) if feature_data['value'] || feature_data[:value]
48
+ if feature_data['value'] || feature_data[:value]
49
+ feature.set_value(feature_data['value'] || feature_data[:value])
50
+ end
49
51
 
50
52
  # Import targeting
51
53
  if feature_data['targeting'] || feature_data[:targeting]
@@ -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, :adapter_registry
11
+ attr_reader :name, :type, :status, :default_value, :description, :display_name, :adapter_registry
12
12
 
13
13
  def initialize(name, adapter_registry, **options)
14
14
  @name = name.to_s
@@ -17,6 +17,7 @@ module Magick
17
17
  @status = (options[:status] || :active).to_sym
18
18
  @default_value = options.fetch(:default_value, default_for_type)
19
19
  @description = options[:description]
20
+ @display_name = options[:name] || options[:display_name]
20
21
  @targeting = {}
21
22
  @dependencies = options[:dependencies] ? Array(options[:dependencies]) : []
22
23
 
@@ -69,28 +70,20 @@ module Magick
69
70
  return false if status == :deprecated && !context[:allow_deprecated]
70
71
 
71
72
  # Check feature dependencies
72
- unless dependencies.empty?
73
- return false unless dependencies.all? { |dep_name| Magick.enabled?(dep_name, context) }
74
- end
73
+ return false if !dependencies.empty? && !dependencies.all? { |dep_name| Magick.enabled?(dep_name, context) }
75
74
 
76
75
  # Check date/time range targeting
77
- if targeting[:date_range] && !date_range_active?(targeting[:date_range])
78
- return false
79
- end
76
+ return false if targeting[:date_range] && !date_range_active?(targeting[:date_range])
80
77
 
81
78
  # Check IP address targeting
82
- if targeting[:ip_address] && context[:ip_address]
83
- return false unless ip_address_matches?(context[:ip_address])
84
- end
79
+ return false if targeting[:ip_address] && context[:ip_address] && !ip_address_matches?(context[:ip_address])
85
80
 
86
81
  # Check custom attributes
87
- if targeting[:custom_attributes]
88
- return false unless custom_attributes_match?(context, targeting[:custom_attributes])
89
- end
82
+ return false if targeting[:custom_attributes] && !custom_attributes_match?(context, targeting[:custom_attributes])
90
83
 
91
84
  # Check complex conditions
92
- if targeting[:complex_conditions]
93
- return false unless complex_conditions_match?(context, targeting[:complex_conditions])
85
+ if targeting[:complex_conditions] && !complex_conditions_match?(context, targeting[:complex_conditions])
86
+ return false
94
87
  end
95
88
 
96
89
  value = get_value(context)
@@ -100,7 +93,7 @@ module Magick
100
93
  when :string
101
94
  !value.nil? && value != ''
102
95
  when :number
103
- value.to_f > 0
96
+ value.to_f.positive?
104
97
  else
105
98
  false
106
99
  end
@@ -110,6 +103,18 @@ module Magick
110
103
  !enabled?(context)
111
104
  end
112
105
 
106
+ def enabled_for?(object, **additional_context)
107
+ # Extract context from object
108
+ context = extract_context_from_object(object)
109
+ # Merge with any additional context provided
110
+ context.merge!(additional_context)
111
+ enabled?(context)
112
+ end
113
+
114
+ def disabled_for?(object, **additional_context)
115
+ !enabled_for?(object, **additional_context)
116
+ end
117
+
113
118
  def value(context = {})
114
119
  get_value(context)
115
120
  end
@@ -125,26 +130,32 @@ module Magick
125
130
 
126
131
  def enable_for_user(user_id)
127
132
  enable_targeting(:user, user_id)
133
+ true
128
134
  end
129
135
 
130
136
  def disable_for_user(user_id)
131
137
  disable_targeting(:user, user_id)
138
+ true
132
139
  end
133
140
 
134
141
  def enable_for_group(group_name)
135
142
  enable_targeting(:group, group_name)
143
+ true
136
144
  end
137
145
 
138
146
  def disable_for_group(group_name)
139
147
  disable_targeting(:group, group_name)
148
+ true
140
149
  end
141
150
 
142
151
  def enable_for_role(role_name)
143
152
  enable_targeting(:role, role_name)
153
+ true
144
154
  end
145
155
 
146
156
  def disable_for_role(role_name)
147
157
  disable_targeting(:role, role_name)
158
+ true
148
159
  end
149
160
 
150
161
  def enable_percentage_of_users(percentage)
@@ -152,18 +163,19 @@ module Magick
152
163
  save_targeting
153
164
 
154
165
  # Update registered feature instance if it exists
155
- if Magick.features.key?(name)
156
- Magick.features[name].instance_variable_set(:@targeting, @targeting.dup)
157
- end
166
+ Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
158
167
 
159
168
  # Rails 8+ event
160
169
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
161
170
  Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_users, targeting_value: percentage)
162
171
  end
172
+
173
+ true
163
174
  end
164
175
 
165
176
  def disable_percentage_of_users
166
177
  disable_targeting(:percentage_users)
178
+ true
167
179
  end
168
180
 
169
181
  def enable_percentage_of_requests(percentage)
@@ -171,40 +183,46 @@ module Magick
171
183
  save_targeting
172
184
 
173
185
  # Update registered feature instance if it exists
174
- if Magick.features.key?(name)
175
- Magick.features[name].instance_variable_set(:@targeting, @targeting.dup)
176
- end
186
+ Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
177
187
 
178
188
  # Rails 8+ event
179
189
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
180
190
  Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_requests, targeting_value: percentage)
181
191
  end
192
+
193
+ true
182
194
  end
183
195
 
184
196
  def disable_percentage_of_requests
185
197
  disable_targeting(:percentage_requests)
198
+ true
186
199
  end
187
200
 
188
201
  def enable_for_date_range(start_date, end_date)
189
202
  enable_targeting(:date_range, { start: start_date, end: end_date })
203
+ true
190
204
  end
191
205
 
192
206
  def disable_date_range
193
207
  disable_targeting(:date_range)
208
+ true
194
209
  end
195
210
 
196
211
  def enable_for_ip_addresses(ip_addresses)
197
212
  enable_targeting(:ip_address, Array(ip_addresses))
213
+ true
198
214
  end
199
215
 
200
216
  def disable_ip_addresses
201
217
  disable_targeting(:ip_address)
218
+ true
202
219
  end
203
220
 
204
221
  def enable_for_custom_attribute(attribute_name, values, operator: :equals)
205
222
  custom_attrs = targeting[:custom_attributes] || {}
206
223
  custom_attrs[attribute_name.to_sym] = { values: Array(values), operator: operator }
207
224
  enable_targeting(:custom_attributes, custom_attrs)
225
+ true
208
226
  end
209
227
 
210
228
  def disable_custom_attribute(attribute_name)
@@ -215,6 +233,7 @@ module Magick
215
233
  else
216
234
  enable_targeting(:custom_attributes, custom_attrs)
217
235
  end
236
+ true
218
237
  end
219
238
 
220
239
  def set_variants(variants)
@@ -227,6 +246,8 @@ module Magick
227
246
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
228
247
  Magick::Rails::Events.variant_set(name, variants: variants_array)
229
248
  end
249
+
250
+ true
230
251
  end
231
252
 
232
253
  def add_dependency(dependency_name)
@@ -237,6 +258,8 @@ module Magick
237
258
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
238
259
  Magick::Rails::Events.dependency_added(name, dependency_name)
239
260
  end
261
+
262
+ true
240
263
  end
241
264
 
242
265
  def remove_dependency(dependency_name)
@@ -246,6 +269,8 @@ module Magick
246
269
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
247
270
  Magick::Rails::Events.dependency_removed(name, dependency_name)
248
271
  end
272
+
273
+ true
249
274
  end
250
275
 
251
276
  def dependencies
@@ -257,26 +282,26 @@ module Magick
257
282
 
258
283
  variants = targeting[:variants]
259
284
  selected_variant = if variants.length == 1
260
- variants.first[:name]
261
- else
262
- # Weighted random selection
263
- total_weight = variants.sum { |v| v[:weight] || 0 }
264
- if total_weight == 0
265
- variants.first[:name]
266
- else
267
- random = rand(total_weight)
268
- current = 0
269
- selected = nil
270
- variants.each do |variant|
271
- current += (variant[:weight] || 0)
272
- if random < current
273
- selected = variant[:name]
274
- break
275
- end
276
- end
277
- selected || variants.first[:name]
278
- end
279
- end
285
+ variants.first[:name]
286
+ else
287
+ # Weighted random selection
288
+ total_weight = variants.sum { |v| v[:weight] || 0 }
289
+ if total_weight.zero?
290
+ variants.first[:name]
291
+ else
292
+ random = rand(total_weight)
293
+ current = 0
294
+ selected = nil
295
+ variants.each do |variant|
296
+ current += variant[:weight] || 0
297
+ if random < current
298
+ selected = variant[:name]
299
+ break
300
+ end
301
+ end
302
+ selected || variants.first[:name]
303
+ end
304
+ end
280
305
 
281
306
  # Rails 8+ event
282
307
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
@@ -294,30 +319,29 @@ module Magick
294
319
  adapter_registry.set(name, 'status', status)
295
320
  adapter_registry.set(name, 'default_value', default_value)
296
321
  adapter_registry.set(name, 'description', description) if description
322
+ adapter_registry.set(name, 'display_name', display_name) if display_name
297
323
  @stored_value = value
298
324
 
299
325
  # Update registered feature instance if it exists
300
- if Magick.features.key?(name)
301
- Magick.features[name].instance_variable_set(:@stored_value, value)
302
- end
326
+ Magick.features[name].instance_variable_set(:@stored_value, value) if Magick.features.key?(name)
303
327
 
304
328
  changes = { value: { from: old_value, to: value } }
305
329
 
306
330
  # Audit log
307
- if Magick.audit_log
308
- Magick.audit_log.log(
309
- name,
310
- 'set_value',
311
- user_id: user_id,
312
- changes: changes
313
- )
314
- end
331
+ Magick.audit_log&.log(
332
+ name,
333
+ 'set_value',
334
+ user_id: user_id,
335
+ changes: changes
336
+ )
315
337
 
316
338
  # Rails 8+ events
317
339
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
318
340
  Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id)
319
341
  Magick::Rails::Events.audit_logged(name, action: 'set_value', user_id: user_id, changes: changes)
320
342
  end
343
+
344
+ true
321
345
  end
322
346
 
323
347
  def enable(user_id: nil)
@@ -329,9 +353,9 @@ module Magick
329
353
  when :boolean
330
354
  set_value(true, user_id: user_id)
331
355
  when :string
332
- raise InvalidFeatureValueError, "Cannot enable string feature. Use set_value instead."
356
+ raise InvalidFeatureValueError, 'Cannot enable string feature. Use set_value instead.'
333
357
  when :number
334
- raise InvalidFeatureValueError, "Cannot enable number feature. Use set_value instead."
358
+ raise InvalidFeatureValueError, 'Cannot enable number feature. Use set_value instead.'
335
359
  else
336
360
  raise InvalidFeatureValueError, "Cannot enable feature of type #{type}"
337
361
  end
@@ -340,6 +364,8 @@ module Magick
340
364
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
341
365
  Magick::Rails::Events.feature_enabled_globally(name, user_id: user_id)
342
366
  end
367
+
368
+ true
343
369
  end
344
370
 
345
371
  def disable(user_id: nil)
@@ -358,10 +384,15 @@ module Magick
358
384
  raise InvalidFeatureValueError, "Cannot disable feature of type #{type}"
359
385
  end
360
386
 
387
+ # Cascade disable: disable all features that depend on this one
388
+ disable_dependent_features(user_id: user_id)
389
+
361
390
  # Rails 8+ event
362
391
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
363
392
  Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id)
364
393
  end
394
+
395
+ true
365
396
  end
366
397
 
367
398
  def set_status(new_status)
@@ -369,6 +400,7 @@ module Magick
369
400
 
370
401
  @status = new_status.to_sym
371
402
  adapter_registry.set(name, 'status', status)
403
+ true
372
404
  end
373
405
 
374
406
  def delete
@@ -377,11 +409,13 @@ module Magick
377
409
  @targeting = {}
378
410
  # Also remove from Magick.features if registered
379
411
  Magick.features.delete(name.to_s)
412
+ true
380
413
  end
381
414
 
382
415
  def to_h
383
416
  {
384
417
  name: name,
418
+ display_name: display_name,
385
419
  type: type,
386
420
  status: status,
387
421
  value: stored_value,
@@ -409,6 +443,7 @@ module Magick
409
443
  registered = Magick.features[name]
410
444
  registered.instance_variable_set(:@stored_value, @stored_value)
411
445
  registered.instance_variable_set(:@status, @status)
446
+ registered.instance_variable_set(:@display_name, @display_name)
412
447
  registered.instance_variable_set(:@targeting, @targeting.dup)
413
448
  end
414
449
  self
@@ -420,6 +455,8 @@ module Magick
420
455
  @stored_value = load_value_from_adapter
421
456
  status_value = adapter_registry.get(name, 'status')
422
457
  @status = status_value ? status_value.to_sym : status
458
+ display_name_value = adapter_registry.get(name, 'display_name')
459
+ @display_name = display_name_value if display_name_value
423
460
  targeting_value = adapter_registry.get(name, 'targeting')
424
461
  if targeting_value.is_a?(Hash)
425
462
  # Normalize keys to symbols and handle nested structures
@@ -438,7 +475,7 @@ module Magick
438
475
 
439
476
  case type
440
477
  when :boolean
441
- value == true || value == 'true' || value == 1
478
+ [true, 'true', 1].include?(value)
442
479
  when :string
443
480
  value.to_s
444
481
  when :number
@@ -588,6 +625,73 @@ module Magick
588
625
  (hash_value % 100) < percentage
589
626
  end
590
627
 
628
+ def extract_context_from_object(object)
629
+ context = {}
630
+
631
+ # Handle hash/struct-like objects
632
+ if object.is_a?(Hash)
633
+ context[:user_id] = object[:user_id] || object['user_id'] || object[:id] || object['id']
634
+ context[:group] = object[:group] || object['group']
635
+ context[:role] = object[:role] || object['role']
636
+ context[:ip_address] = object[:ip_address] || object['ip_address']
637
+ # Include all other attributes for custom attribute matching
638
+ object.each do |key, value|
639
+ next if %i[user_id id group role ip_address].include?(key.to_sym)
640
+ next if %w[user_id id group role ip_address].include?(key.to_s)
641
+
642
+ context[key.to_sym] = value
643
+ end
644
+ # Handle ActiveRecord-like objects (respond to methods)
645
+ elsif object.respond_to?(:id) || object.respond_to?(:user_id)
646
+ context[:user_id] = object.respond_to?(:user_id) ? object.user_id : object.id
647
+ context[:group] = object.group if object.respond_to?(:group)
648
+ context[:role] = object.role if object.respond_to?(:role)
649
+ context[:ip_address] = object.ip_address if object.respond_to?(:ip_address)
650
+
651
+ # For ActiveRecord objects, include all attributes
652
+ if object.respond_to?(:attributes)
653
+ object.attributes.each do |key, value|
654
+ next if %w[id user_id group role ip_address].include?(key.to_s)
655
+
656
+ context[key.to_sym] = value
657
+ end
658
+ end
659
+ # Handle simple values (like user_id directly)
660
+ elsif object.respond_to?(:to_i) && object.to_i.to_s == object.to_s
661
+ context[:user_id] = object.to_i
662
+ end
663
+
664
+ context
665
+ end
666
+
667
+ def disable_dependent_features(user_id: nil)
668
+ # Find all features that depend on this feature
669
+ dependent_features = find_dependent_features
670
+
671
+ # Disable each dependent feature
672
+ dependent_features.each do |dep_feature_name|
673
+ dep_feature = Magick.features[dep_feature_name.to_s] || Magick[dep_feature_name]
674
+ next unless dep_feature
675
+
676
+ # Only disable if it's currently enabled (to avoid unnecessary operations)
677
+ if dep_feature.enabled?
678
+ dep_feature.disable(user_id: user_id)
679
+ end
680
+ end
681
+ end
682
+
683
+ def find_dependent_features
684
+ # Find all features that have this feature in their dependencies
685
+ dependent_features = []
686
+ Magick.features.each do |_name, feature|
687
+ feature_deps = feature.instance_variable_get(:@dependencies) || []
688
+ if feature_deps.include?(name.to_s) || feature_deps.include?(name.to_sym)
689
+ dependent_features << feature.name
690
+ end
691
+ end
692
+ dependent_features
693
+ end
694
+
591
695
  def enable_targeting(type, value)
592
696
  @targeting[type] ||= []
593
697
  @targeting[type] << value.to_s unless @targeting[type].include?(value.to_s)
@@ -597,6 +701,8 @@ module Magick
597
701
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
598
702
  Magick::Rails::Events.targeting_added(name, targeting_type: type, targeting_value: value)
599
703
  end
704
+
705
+ true
600
706
  end
601
707
 
602
708
  def disable_targeting(type, value = nil)
@@ -611,14 +717,16 @@ module Magick
611
717
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
612
718
  Magick::Rails::Events.targeting_removed(name, targeting_type: type, targeting_value: value)
613
719
  end
720
+
721
+ true
614
722
  end
615
723
 
616
724
  def save_targeting
617
725
  adapter_registry.set(name, 'targeting', targeting)
618
726
  # Update the feature in Magick.features if it's registered
619
- if Magick.features.key?(name)
620
- Magick.features[name].instance_variable_set(:@targeting, targeting.dup)
621
- end
727
+ return unless Magick.features.key?(name)
728
+
729
+ Magick.features[name].instance_variable_set(:@targeting, targeting.dup)
622
730
  end
623
731
 
624
732
  def default_for_type
@@ -643,22 +751,30 @@ module Magick
643
751
  def validate_default_value!
644
752
  case type
645
753
  when :boolean
646
- raise InvalidFeatureValueError, "Default value must be boolean for type :boolean" unless [true, false].include?(default_value)
754
+ unless [true, false].include?(default_value)
755
+ raise InvalidFeatureValueError, 'Default value must be boolean for type :boolean'
756
+ end
647
757
  when :string
648
- raise InvalidFeatureValueError, "Default value must be a string for type :string" unless default_value.is_a?(String)
758
+ unless default_value.is_a?(String)
759
+ raise InvalidFeatureValueError,
760
+ 'Default value must be a string for type :string'
761
+ end
649
762
  when :number
650
- raise InvalidFeatureValueError, "Default value must be numeric for type :number" unless default_value.is_a?(Numeric)
763
+ unless default_value.is_a?(Numeric)
764
+ raise InvalidFeatureValueError,
765
+ 'Default value must be numeric for type :number'
766
+ end
651
767
  end
652
768
  end
653
769
 
654
770
  def validate_value!(value)
655
771
  case type
656
772
  when :boolean
657
- raise InvalidFeatureValueError, "Value must be boolean for type :boolean" unless [true, false].include?(value)
773
+ raise InvalidFeatureValueError, 'Value must be boolean for type :boolean' unless [true, false].include?(value)
658
774
  when :string
659
- raise InvalidFeatureValueError, "Value must be a string for type :string" unless value.is_a?(String)
775
+ raise InvalidFeatureValueError, 'Value must be a string for type :string' unless value.is_a?(String)
660
776
  when :number
661
- raise InvalidFeatureValueError, "Value must be numeric for type :number" unless value.is_a?(Numeric)
777
+ raise InvalidFeatureValueError, 'Value must be numeric for type :number' unless value.is_a?(Numeric)
662
778
  end
663
779
  end
664
780
  end
@@ -24,17 +24,25 @@ module Magick
24
24
  end
25
25
  end
26
26
 
27
- def initialize
27
+ def initialize(batch_size: 100, flush_interval: 60)
28
28
  @metrics = []
29
29
  @mutex = Mutex.new
30
30
  @usage_count = Hash.new(0)
31
+ @pending_updates = Hash.new(0) # For Redis batching
32
+ @batch_size = batch_size
33
+ @flush_interval = flush_interval
34
+ @last_flush = Time.now
35
+ @redis_enabled = false
31
36
  end
32
37
 
33
38
  def record(feature_name, operation, duration, success: true)
34
- metric = Metric.new(feature_name, operation, duration, success: success)
39
+ feature_name_str = feature_name.to_s
40
+ metric = Metric.new(feature_name_str, operation, duration, success: success)
41
+
35
42
  @mutex.synchronize do
36
43
  @metrics << metric
37
- @usage_count[feature_name.to_s] += 1
44
+ @usage_count[feature_name_str] += 1
45
+ @pending_updates[feature_name_str] += 1
38
46
  # Keep only last 1000 metrics
39
47
  @metrics.shift if @metrics.length > 1000
40
48
  end
@@ -44,9 +52,64 @@ module Magick
44
52
  Magick::Rails::Events.usage_tracked(feature_name, operation: operation, duration: duration, success: success)
45
53
  end
46
54
 
55
+ # Batch flush to Redis if needed
56
+ flush_to_redis_if_needed
57
+
47
58
  metric
48
59
  end
49
60
 
61
+ def flush_to_redis_if_needed
62
+ return unless @redis_enabled
63
+ return if @pending_updates.empty?
64
+
65
+ should_flush = false
66
+ @mutex.synchronize do
67
+ # Flush if we have enough pending updates or enough time has passed
68
+ if @pending_updates.size >= @batch_size || (Time.now - @last_flush) >= @flush_interval
69
+ should_flush = true
70
+ end
71
+ end
72
+
73
+ flush_to_redis if should_flush
74
+ end
75
+
76
+ def flush_to_redis
77
+ updates_to_flush = nil
78
+ @mutex.synchronize do
79
+ return if @pending_updates.empty?
80
+
81
+ updates_to_flush = @pending_updates.dup
82
+ @pending_updates.clear
83
+ @last_flush = Time.now
84
+ end
85
+
86
+ return if updates_to_flush.nil? || updates_to_flush.empty?
87
+
88
+ # Update Redis in batch
89
+ begin
90
+ adapter = Magick.adapter_registry || Magick.default_adapter_registry
91
+ if adapter.is_a?(Magick::Adapters::Registry) && adapter.redis_adapter
92
+ redis = adapter.redis_adapter.instance_variable_get(:@redis)
93
+ if redis
94
+ updates_to_flush.each do |feature_name, count|
95
+ redis_key = "magick:stats:#{feature_name}"
96
+ redis.incrby(redis_key, count)
97
+ redis.expire(redis_key, 86400 * 7) # Keep stats for 7 days
98
+ end
99
+ end
100
+ end
101
+ rescue StandardError => e
102
+ # Silently fail - don't break feature checks if stats fail
103
+ warn "Magick: Failed to flush stats to Redis: #{e.message}" if defined?(Rails) && Rails.env.development?
104
+ end
105
+ end
106
+
107
+ def enable_redis_tracking(enable: true)
108
+ @redis_enabled = enable
109
+ # Flush any pending updates when enabling
110
+ flush_to_redis if enable && !@pending_updates.empty?
111
+ end
112
+
50
113
  def average_duration(feature_name: nil, operation: nil)
51
114
  filtered = @metrics.select do |m|
52
115
  (feature_name.nil? || m.feature_name == feature_name.to_s) &&
@@ -59,17 +122,60 @@ module Magick
59
122
  end
60
123
 
61
124
  def usage_count(feature_name)
62
- @usage_count[feature_name.to_s] || 0
125
+ feature_name_str = feature_name.to_s
126
+ memory_count = @usage_count[feature_name_str] || 0
127
+
128
+ # Also check Redis if enabled
129
+ redis_count = 0
130
+ if @redis_enabled
131
+ begin
132
+ adapter = Magick.adapter_registry || Magick.default_adapter_registry
133
+ if adapter.is_a?(Magick::Adapters::Registry) && adapter.redis_adapter
134
+ redis = adapter.redis_adapter.instance_variable_get(:@redis)
135
+ if redis
136
+ redis_key = "magick:stats:#{feature_name_str}"
137
+ redis_count = redis.get(redis_key).to_i
138
+ end
139
+ end
140
+ rescue StandardError
141
+ # Silently fail
142
+ end
143
+ end
144
+
145
+ memory_count + redis_count
63
146
  end
64
147
 
65
148
  def most_used_features(limit: 10)
66
- @usage_count.sort_by { |_name, count| -count }.first(limit).to_h
149
+ # Combine memory and Redis counts
150
+ combined_counts = @usage_count.dup
151
+
152
+ if @redis_enabled
153
+ begin
154
+ adapter = Magick.adapter_registry || Magick.default_adapter_registry
155
+ if adapter.is_a?(Magick::Adapters::Registry) && adapter.redis_adapter
156
+ redis = adapter.redis_adapter.instance_variable_get(:@redis)
157
+ if redis
158
+ # Get all stats keys
159
+ redis.keys("magick:stats:*").each do |key|
160
+ feature_name = key.to_s.sub('magick:stats:', '')
161
+ redis_count = redis.get(key).to_i
162
+ combined_counts[feature_name] = (combined_counts[feature_name] || 0) + redis_count
163
+ end
164
+ end
165
+ end
166
+ rescue StandardError
167
+ # Silently fail
168
+ end
169
+ end
170
+
171
+ combined_counts.sort_by { |_name, count| -count }.first(limit).to_h
67
172
  end
68
173
 
69
174
  def clear!
70
175
  @mutex.synchronize do
71
176
  @metrics.clear
72
177
  @usage_count.clear
178
+ @pending_updates.clear
73
179
  end
74
180
  end
75
181
  end
@@ -20,7 +20,7 @@ module Magick
20
20
  end
21
21
 
22
22
  # Subscribe to a specific event
23
- def subscribe_to(event_name, &block)
23
+ def subscribe_to(event_name)
24
24
  return unless Events.rails81?
25
25
 
26
26
  full_event_name = Events::EVENTS[event_name] || event_name.to_s
@@ -44,7 +44,7 @@ module Magick
44
44
  # Default log subscriber for Magick events
45
45
  class LogSubscriber
46
46
  def emit(event)
47
- payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(" ")
47
+ payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(' ')
48
48
  source_location = event[:source_location]
49
49
  log = "[#{event[:name]}] #{payload}"
50
50
  log += " at #{source_location[:filepath]}:#{source_location[:lineno]}" if source_location
@@ -43,20 +43,22 @@ if defined?(Rails)
43
43
  else
44
44
  # Fallback to config/initializers/features.rb (already loaded by Rails, but check anyway)
45
45
  initializer_file = Rails.root.join('config', 'initializers', 'features.rb')
46
- if File.exist?(initializer_file)
46
+ if File.exist?(initializer_file) && !defined?(Magick::Rails::FeaturesLoaded)
47
47
  # Only load if not already loaded (Rails may have already loaded it)
48
- load initializer_file unless defined?(Magick::Rails::FeaturesLoaded)
48
+ load initializer_file
49
49
  end
50
50
  end
51
- Magick::Rails.const_set(:FeaturesLoaded, true) rescue nil
51
+ begin
52
+ Magick::Rails.const_set(:FeaturesLoaded, true)
53
+ rescue StandardError
54
+ nil
55
+ end
52
56
  end
53
57
  end
54
58
 
55
59
  # Preload features in request store
56
60
  config.to_prepare do
57
- if defined?(RequestStore)
58
- RequestStore.store[:magick_features] ||= {}
59
- end
61
+ RequestStore.store[:magick_features] ||= {} if defined?(RequestStore)
60
62
  end
61
63
  end
62
64
  end
data/lib/magick/rails.rb CHANGED
@@ -2,6 +2,4 @@
2
2
 
3
3
  # Rails integration is now in lib/magick/rails/railtie.rb
4
4
  # This file is kept for backward compatibility
5
- if defined?(Rails)
6
- require_relative 'rails/railtie'
7
- end
5
+ require_relative 'rails/railtie' if defined?(Rails)
@@ -7,7 +7,7 @@ module Magick
7
7
  end
8
8
 
9
9
  module ClassMethods
10
- def with_feature_enabled(feature_name, &block)
10
+ def with_feature_enabled(feature_name)
11
11
  feature = Magick.features[feature_name.to_s] || Magick[feature_name]
12
12
  original_value = feature.value
13
13
  feature.set_value(true)
@@ -16,7 +16,7 @@ module Magick
16
16
  feature.set_value(original_value)
17
17
  end
18
18
 
19
- def with_feature_disabled(feature_name, &block)
19
+ def with_feature_disabled(feature_name)
20
20
  feature = Magick.features[feature_name.to_s] || Magick[feature_name]
21
21
  original_value = feature.value
22
22
  feature.set_value(false)
@@ -25,7 +25,7 @@ module Magick
25
25
  feature.set_value(original_value)
26
26
  end
27
27
 
28
- def with_feature_value(feature_name, value, &block)
28
+ def with_feature_value(feature_name, value)
29
29
  feature = Magick.features[feature_name.to_s] || Magick[feature_name]
30
30
  original_value = feature.value
31
31
  feature.set_value(value)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '0.7.4'
4
+ VERSION = '0.8.2'
5
5
  end
@@ -61,17 +61,15 @@ module Magick
61
61
  feature.set_status(feature_data[:status]) if feature_data[:status]
62
62
 
63
63
  # Restore targeting
64
- if feature_data[:targeting]
65
- feature_data[:targeting].each do |type, values|
66
- Array(values).each do |value|
67
- case type.to_sym
68
- when :user
69
- feature.enable_for_user(value)
70
- when :group
71
- feature.enable_for_group(value)
72
- when :role
73
- feature.enable_for_role(value)
74
- end
64
+ feature_data[:targeting]&.each do |type, values|
65
+ Array(values).each do |value|
66
+ case type.to_sym
67
+ when :user
68
+ feature.enable_for_user(value)
69
+ when :group
70
+ feature.enable_for_group(value)
71
+ when :role
72
+ feature.enable_for_role(value)
75
73
  end
76
74
  end
77
75
  end
data/lib/magick.rb CHANGED
@@ -39,7 +39,7 @@ module Magick
39
39
  # Support both old style and new DSL style
40
40
  return unless block_given?
41
41
 
42
- if block.arity == 0
42
+ if block.arity.zero?
43
43
  # New DSL style
44
44
  ConfigDSL.configure(&block)
45
45
  else
@@ -68,6 +68,15 @@ module Magick
68
68
  feature.enabled?(context)
69
69
  end
70
70
 
71
+ def enabled_for?(feature_name, object, **additional_context)
72
+ feature = features[feature_name.to_s] || self[feature_name]
73
+ feature.enabled_for?(object, **additional_context)
74
+ end
75
+
76
+ def disabled_for?(feature_name, object, **additional_context)
77
+ !enabled_for?(feature_name, object, **additional_context)
78
+ end
79
+
71
80
  # Reload a feature from the adapter (useful when feature is changed externally)
72
81
  def reload_feature(feature_name)
73
82
  feature = features[feature_name.to_s] || self[feature_name]
@@ -82,7 +91,7 @@ module Magick
82
91
  features.key?(feature_name.to_s) || (adapter_registry || default_adapter_registry).exists?(feature_name)
83
92
  end
84
93
 
85
- def bulk_enable(feature_names, context = {})
94
+ def bulk_enable(feature_names, _context = {})
86
95
  feature_names.map do |name|
87
96
  feature = features[name.to_s] || self[name]
88
97
  feature.set_value(true) if feature.type == :boolean
@@ -90,7 +99,7 @@ module Magick
90
99
  end
91
100
  end
92
101
 
93
- def bulk_disable(feature_names, context = {})
102
+ def bulk_disable(feature_names, _context = {})
94
103
  feature_names.map do |name|
95
104
  feature = features[name.to_s] || self[name]
96
105
  feature.set_value(false) if feature.type == :boolean
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: magick-feature-flags
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.4
4
+ version: 0.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-11-18 00:00:00.000000000 Z
11
+ date: 2025-11-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec