magick-feature-flags 0.9.24 → 0.9.26

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: 9aaecf68655a77ae48f4f7b2d7f760528a4aa14aa5f37b5f8a603897e3040d40
4
- data.tar.gz: 8b9694162f06fded4597aaadd14334c515f996979161915004ef113a979aba38
3
+ metadata.gz: 52c961b4ce8e667ce8e728098f63a210fb82aaee03d63d573ef87d692c007875
4
+ data.tar.gz: 6e33c899e93f039bf5d318a0ac1492efa45329b99bbe4e700f17b37d4240818e
5
5
  SHA512:
6
- metadata.gz: 4f47eaddee984442954a6a9f556c434469f8f9c923c93daa4971e232ff0b8ee248c96a510c505c3caecd99ed38687ec2a27544d8624837030a767b13b6253d0f
7
- data.tar.gz: e2523e3c4051c6922638bdf6739eb3dc89e92822a1a93b0f542d6827bb5706a59d8de3bf2d56d1125584d5e20c016f92b3314b9f1f6ac047cab0ce828b00ad7d
6
+ metadata.gz: f854aa3d288dd2bf137fb56e69c58c2092ff1b5d76a8ce2c429f416416b758ab5d99e680efd3c704f084d08dc7b2a4bc2f161bed9431446609de1ebddec40f22
7
+ data.tar.gz: 98e43b058ed905b6bed109911e6649b8644c401681e269a6e406f41e49689992bed804488ab81f0ccafe4db7dcbc68ab45a88686a93f0ee10213f35b8a8f1c36
data/README.md CHANGED
@@ -33,7 +33,7 @@ Or install it yourself as:
33
33
  $ gem install magick
34
34
  ```
35
35
 
36
- ## Installation
36
+ ## Setup
37
37
 
38
38
  After adding the gem to your Gemfile and running `bundle install`, generate the configuration file:
39
39
 
@@ -43,6 +43,22 @@ rails generate magick:install
43
43
 
44
44
  This will create `config/initializers/magick.rb` with a basic configuration.
45
45
 
46
+ ### ActiveRecord Adapter (Optional)
47
+
48
+ If you want to use ActiveRecord as a persistent storage backend, generate the migration:
49
+
50
+ ```bash
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
+ rails db:migrate
58
+ ```
59
+
60
+ **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
+
46
62
  ## Configuration
47
63
 
48
64
  ### Basic Configuration
@@ -95,6 +111,9 @@ Magick.configure do
95
111
  audit_log enabled: true
96
112
  versioning enabled: true
97
113
  warn_on_deprecated enabled: true
114
+
115
+ # Enable Admin UI (optional)
116
+ admin_ui enabled: true
98
117
  end
99
118
  ```
100
119
 
@@ -434,6 +453,186 @@ With Redis configured:
434
453
 
435
454
  **Important:** By default, Magick uses Redis database 1 to avoid conflicts with Rails cache (which typically uses database 0). This ensures that clearing Rails cache (`Rails.cache.clear`) won't affect your feature toggle states.
436
455
 
456
+ #### ActiveRecord Adapter (Optional)
457
+
458
+ The ActiveRecord adapter provides database-backed persistent storage for feature flags. It's useful when you want to:
459
+ - Store feature flags in your application database
460
+ - Use ActiveRecord models for feature management
461
+ - Have a fallback storage layer
462
+ - Work with PostgreSQL, MySQL, SQLite, or any ActiveRecord-supported database
463
+
464
+ **Setup:**
465
+
466
+ 1. Generate the migration:
467
+ ```bash
468
+ rails generate magick:active_record
469
+ rails db:migrate
470
+ ```
471
+
472
+ **With UUID primary keys:**
473
+ ```bash
474
+ rails generate magick:active_record --uuid
475
+ ```
476
+
477
+ 2. Configure in `config/initializers/magick.rb`:
478
+ ```ruby
479
+ Magick.configure do
480
+ active_record # Uses default MagickFeature model
481
+ # Or specify a custom model:
482
+ # active_record model_class: YourCustomModel
483
+ end
484
+ ```
485
+
486
+ The adapter automatically creates the `magick_features` table if it doesn't exist, but using the generator is recommended for production applications.
487
+
488
+ **PostgreSQL Support:**
489
+
490
+ The generator automatically detects PostgreSQL and uses `jsonb` for the `data` column, providing:
491
+ - Better performance with native JSON queries
492
+ - Native JSON indexing and querying capabilities
493
+ - Type-safe JSON storage
494
+
495
+ For other databases (MySQL, SQLite, etc.), it uses `text` with serialized JSON.
496
+
497
+ **UUID Primary Keys:**
498
+
499
+ When using the `--uuid` flag:
500
+ - Creates table with `id: :uuid` instead of integer primary key
501
+ - Enables `pgcrypto` extension for PostgreSQL (required for UUID generation)
502
+ - Works with other databases using their native UUID support
503
+
504
+ **Note:** The ActiveRecord adapter works as a fallback in the adapter chain: Memory → Redis → ActiveRecord. It's automatically included if ActiveRecord is available and configured.
505
+
506
+ **Adapter Chain:**
507
+
508
+ The adapter registry uses a fallback strategy:
509
+ 1. **Memory Adapter** (first) - Fast, in-memory lookups
510
+ 2. **Redis Adapter** (second) - Persistent, distributed storage
511
+ 3. **ActiveRecord Adapter** (third) - Database-backed fallback
512
+
513
+ When a feature is requested:
514
+ - First checks memory cache (fastest)
515
+ - Falls back to Redis if not in memory
516
+ - Falls back to ActiveRecord if Redis is unavailable or returns nil
517
+ - Updates all adapters when features are modified
518
+
519
+ This ensures maximum performance while maintaining persistence and reliability.
520
+
521
+ ### Admin UI
522
+
523
+ Magick includes a web-based Admin UI for managing feature flags. It's a Rails Engine that provides a user-friendly interface for viewing, enabling, disabling, and configuring features.
524
+
525
+ **Setup:**
526
+
527
+ 1. Enable Admin UI in `config/initializers/magick.rb`:
528
+
529
+ ```ruby
530
+ Magick.configure do
531
+ admin_ui enabled: true
532
+ end
533
+ ```
534
+
535
+ 2. Configure roles (optional) for targeting management:
536
+
537
+ ```ruby
538
+ Magick::AdminUI.configure do |config|
539
+ config.available_roles = ['admin', 'user', 'manager', 'guest']
540
+ end
541
+ ```
542
+
543
+ 3. Mount the engine in `config/routes.rb`:
544
+
545
+ ```ruby
546
+ Rails.application.routes.draw do
547
+ # ... your other routes ...
548
+
549
+ # With authentication (recommended for production)
550
+ authenticate :admin_user do
551
+ mount Magick::AdminUI::Engine, at: '/magick'
552
+ end
553
+
554
+ # Or without authentication (development only)
555
+ # mount Magick::AdminUI::Engine, at: '/magick'
556
+ end
557
+ ```
558
+
559
+ **Access:**
560
+
561
+ Once mounted, visit `/magick` in your browser to access the Admin UI.
562
+
563
+ **Features:**
564
+
565
+ - **Feature List**: View all registered features with their current status, type, and description
566
+ - **Feature Details**: View detailed information about each feature including:
567
+ - Current value/status
568
+ - Targeting rules (users, groups, roles, percentages, etc.)
569
+ - Performance statistics (usage count, average duration)
570
+ - Feature metadata (type, default value, dependencies)
571
+ - **Enable/Disable**: Quickly enable or disable features globally
572
+ - **Targeting Management**: Configure targeting rules through a user-friendly interface:
573
+ - **Role Targeting**: Select roles from a configured list (checkboxes)
574
+ - **User Targeting**: Enter user IDs (comma-separated)
575
+ - **Visual Display**: See all active targeting rules with badges
576
+ - **Edit Features**: Update feature values (boolean, string, number) directly from the UI
577
+ - **Statistics**: View performance metrics and usage statistics for each feature
578
+
579
+ **Targeting Management:**
580
+
581
+ The Admin UI provides a comprehensive targeting interface:
582
+
583
+ 1. **Role Targeting**:
584
+ - Configure available roles via `Magick::AdminUI.configure`
585
+ - Select multiple roles using checkboxes
586
+ - Roles are automatically added/removed when checkboxes are toggled
587
+
588
+ 2. **User Targeting**:
589
+ - Enter user IDs as comma-separated values (e.g., `123, 456, 789`)
590
+ - Add or remove users dynamically
591
+ - Clear all user targeting by leaving the field empty
592
+
593
+ 3. **Visual Feedback**:
594
+ - All targeting rules are displayed as badges in the feature details view
595
+ - Easy to see which roles/users have access to each feature
596
+
597
+ **Routes:**
598
+
599
+ The Admin UI provides the following routes:
600
+
601
+ - `GET /magick` - Feature list (index)
602
+ - `GET /magick/features/:id` - Feature details
603
+ - `GET /magick/features/:id/edit` - Edit feature
604
+ - `PUT /magick/features/:id` - Update feature value
605
+ - `PUT /magick/features/:id/enable` - Enable feature globally
606
+ - `PUT /magick/features/:id/disable` - Disable feature globally
607
+ - `PUT /magick/features/:id/enable_for_user` - Enable feature for specific user
608
+ - `PUT /magick/features/:id/enable_for_role` - Enable feature for specific role
609
+ - `PUT /magick/features/:id/disable_for_role` - Disable feature for specific role
610
+ - `PUT /magick/features/:id/update_targeting` - Update targeting rules (roles and users)
611
+ - `GET /magick/stats/:id` - View feature statistics
612
+
613
+ **Security:**
614
+
615
+ The Admin UI is a basic Rails Engine without built-in authentication. **You should add authentication/authorization** before mounting it in production. For example:
616
+
617
+ ```ruby
618
+ # config/routes.rb
619
+ Rails.application.routes.draw do
620
+ # Using Devise
621
+ authenticate :admin_user do
622
+ mount Magick::AdminUI::Engine, at: '/magick'
623
+ end
624
+
625
+ # Or using session-based authentication
626
+ constraints(->(request) { request.session[:user_id].present? && request.session[:admin] }) do
627
+ mount Magick::AdminUI::Engine, at: '/magick'
628
+ end
629
+ end
630
+ ```
631
+
632
+ Or use a before_action in your ApplicationController if you mount it at the application level.
633
+
634
+ **Note:** The Admin UI is optional and only loaded when explicitly enabled in configuration. It requires Rails to be available.
635
+
437
636
  ### Feature Types
438
637
 
439
638
  - `:boolean` - True/false flags
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module Generators
5
+ class ActiveRecordGenerator < Rails::Generators::Base
6
+ include Rails::Generators::Migration
7
+
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ desc 'Creates a migration for Magick feature flags table (ActiveRecord adapter)'
11
+ class_option :uuid, type: :boolean, default: false, desc: 'Use UUID as primary key'
12
+
13
+ def self.next_migration_number(dirname)
14
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
15
+ end
16
+
17
+ def create_migration
18
+ unless defined?(::ActiveRecord::Base)
19
+ say 'ActiveRecord is not available. This generator requires ActiveRecord.', :red
20
+ exit 1
21
+ end
22
+
23
+ migration_number = self.class.next_migration_number('db/migrate')
24
+ @use_uuid = options[:uuid]
25
+ @is_postgresql = postgresql?
26
+ template 'create_magick_features.rb', "db/migrate/#{migration_number}_create_magick_features.rb"
27
+ end
28
+
29
+ private
30
+
31
+ def postgresql?
32
+ return false unless defined?(::ActiveRecord::Base)
33
+
34
+ begin
35
+ adapter = ActiveRecord::Base.connection.adapter_name.downcase
36
+ adapter == 'postgresql' || adapter == 'postgis'
37
+ rescue StandardError
38
+ # If we can't connect, check database.yml or default to false
39
+ false
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateMagickFeatures < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ <% if @use_uuid -%>
6
+ enable_extension 'pgcrypto' if adapter_name == 'PostgreSQL'
7
+ create_table :magick_features, id: :uuid do |t|
8
+ <% else -%>
9
+ create_table :magick_features do |t|
10
+ <% end -%>
11
+ t.string :feature_name, null: false, index: { unique: true }
12
+ <% if @is_postgresql -%>
13
+ t.jsonb :data
14
+ <% else -%>
15
+ t.text :data
16
+ <% end -%>
17
+ t.timestamps
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ module Adapters
5
+ class ActiveRecord < Base
6
+ @table_created_mutex = Mutex.new
7
+ @table_created = false
8
+
9
+ def initialize(model_class: nil)
10
+ @model_class = model_class || default_model_class
11
+ ensure_table_exists
12
+ rescue StandardError => e
13
+ raise AdapterError, "Failed to initialize ActiveRecord adapter: #{e.message}"
14
+ end
15
+
16
+ def get(feature_name, key)
17
+ ensure_table_exists unless @model_class.table_exists?
18
+ feature_name_str = feature_name.to_s
19
+ record = @model_class.find_by(feature_name: feature_name_str)
20
+ return nil unless record
21
+
22
+ # Handle both Hash (from serialize) and Hash/JSON (from attribute :json)
23
+ data = record.data || {}
24
+ value = data.is_a?(Hash) ? data[key.to_s] : nil
25
+ deserialize_value(value)
26
+ 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
+ raise AdapterError, "Failed to get from ActiveRecord: #{e.message}"
33
+ end
34
+
35
+ def set(feature_name, key, value)
36
+ ensure_table_exists unless @model_class.table_exists?
37
+ feature_name_str = feature_name.to_s
38
+ retries = 5
39
+ begin
40
+ record = @model_class.find_or_initialize_by(feature_name: feature_name_str)
41
+ # Ensure data is a Hash (works for both serialize and attribute :json)
42
+ data = record.data || {}
43
+ data = {} unless data.is_a?(Hash)
44
+ data[key.to_s] = serialize_value(value)
45
+ record.data = data
46
+ # Use Time.now if Time.current is not available (for non-Rails environments)
47
+ record.updated_at = defined?(Time.current) ? Time.current : Time.now
48
+ record.save!
49
+ rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::ConnectionTimeoutError => e
50
+ # 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
52
+ 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
+ sleep(0.01 * (6 - retries)) # Exponential backoff: 0.01, 0.02, 0.03, 0.04, 0.05
58
+ retry
59
+ end
60
+ raise AdapterError, "Failed to set in ActiveRecord: #{e.message}"
61
+ 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
+ raise AdapterError, "Failed to set in ActiveRecord: #{e.message}"
70
+ end
71
+ end
72
+
73
+ def delete(feature_name)
74
+ ensure_table_exists unless @model_class.table_exists?
75
+ feature_name_str = feature_name.to_s
76
+ retries = 5
77
+ begin
78
+ @model_class.where(feature_name: feature_name_str).destroy_all
79
+ rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::ConnectionTimeoutError => e
80
+ # 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
82
+ 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
+ sleep(0.01 * (6 - retries)) # Exponential backoff: 0.01, 0.02, 0.03, 0.04, 0.05
88
+ retry
89
+ end
90
+ raise AdapterError, "Failed to delete from ActiveRecord: #{e.message}"
91
+ 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
+ raise AdapterError, "Failed to delete from ActiveRecord: #{e.message}"
100
+ end
101
+ end
102
+
103
+ def exists?(feature_name)
104
+ @model_class.exists?(feature_name: feature_name.to_s)
105
+ rescue StandardError => e
106
+ raise AdapterError, "Failed to check existence in ActiveRecord: #{e.message}"
107
+ end
108
+
109
+ def all_features
110
+ @model_class.pluck(:feature_name).uniq
111
+ rescue StandardError => e
112
+ raise AdapterError, "Failed to get all features from ActiveRecord: #{e.message}"
113
+ end
114
+
115
+ private
116
+
117
+ def default_model_class
118
+ return MagickFeature if defined?(MagickFeature)
119
+
120
+ # Create model class if it doesn't exist
121
+ create_model_class
122
+ end
123
+
124
+ def create_model_class
125
+ # Define the model class dynamically
126
+ # Use ::ActiveRecord::VERSION to access from global namespace
127
+ ar_major = ::ActiveRecord::VERSION::MAJOR
128
+ ar_minor = ::ActiveRecord::VERSION::MINOR
129
+ use_json = ar_major >= 8 || (ar_major == 7 && ar_minor >= 1)
130
+
131
+ Object.const_set('MagickFeature', Class.new(::ActiveRecord::Base) do
132
+ self.table_name = 'magick_features'
133
+
134
+ # ActiveRecord 8.1 changed serialize signature - it now only accepts one argument
135
+ # Use attribute :data, :json for ActiveRecord 7.1+ (including 8.1)
136
+ # Fall back to serialize for older versions
137
+ if use_json
138
+ # ActiveRecord 7.1+ and 8.x use attribute with type
139
+ attribute :data, :json, default: {}
140
+ else
141
+ # Older ActiveRecord versions use serialize
142
+ serialize :data, Hash
143
+ end
144
+
145
+ def self.table_exists?
146
+ connection.table_exists?(table_name)
147
+ end
148
+ end)
149
+ end
150
+
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
+ def serialize_value(value)
212
+ # For ActiveRecord 8.1+ with attribute :json, we can store booleans as-is
213
+ # For older versions with serialize, we convert to strings
214
+ ar_major = ::ActiveRecord::VERSION::MAJOR
215
+ ar_minor = ::ActiveRecord::VERSION::MINOR
216
+ use_json = ar_major >= 8 || (ar_major == 7 && ar_minor >= 1)
217
+
218
+ case value
219
+ when Hash, Array
220
+ value
221
+ when true
222
+ use_json ? true : 'true'
223
+ when false
224
+ use_json ? false : 'false'
225
+ else
226
+ value
227
+ end
228
+ end
229
+
230
+ def deserialize_value(value)
231
+ return nil if value.nil?
232
+
233
+ # For ActiveRecord 8.1+ with attribute :json, booleans are already booleans
234
+ # For older versions with serialize, we convert from strings
235
+ case value
236
+ when Hash
237
+ # JSON serialization converts symbol keys to strings
238
+ # Convert string keys back to symbols for consistency with input
239
+ symbolize_hash_keys(value)
240
+ when Array
241
+ # Recursively process array elements
242
+ value.map { |v| v.is_a?(Hash) ? symbolize_hash_keys(v) : v }
243
+ when 'true'
244
+ # String 'true' from older serialize - convert to boolean
245
+ true
246
+ when 'false'
247
+ # String 'false' from older serialize - convert to boolean
248
+ false
249
+ when true, false
250
+ # Already a boolean (from JSON attribute)
251
+ value
252
+ else
253
+ value
254
+ end
255
+ end
256
+
257
+ def symbolize_hash_keys(hash)
258
+ return hash unless hash.is_a?(Hash)
259
+
260
+ hash.each_with_object({}) do |(k, v), result|
261
+ key = k.is_a?(String) ? k.to_sym : k
262
+ result[key] = v.is_a?(Hash) ? symbolize_hash_keys(v) : (v.is_a?(Array) ? v.map { |item| item.is_a?(Hash) ? symbolize_hash_keys(item) : item } : v)
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end