subflag-rails 0.3.0 → 0.4.0

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: 2d5585ee53dcf0c1864f3d9bf5845a41cd8e3c884fe11ca457bc51b3ded8cfcd
4
- data.tar.gz: 1014353a80ee041027dd501aea4035f7043b873dbf75506678e2852b948f436a
3
+ metadata.gz: da40f567a605d55076aaeabed1edd404bb41ba2a1703c56595e42611ebf4aaa4
4
+ data.tar.gz: 39df667c30500ebf560e90694213f05b479d60f488575c7f2503409bc640222d
5
5
  SHA512:
6
- metadata.gz: 7a0b5adb5b8df94ed4e07ba41e47b4430b719b2cf741695b6f9029239aefa6437535d36fb08d9b2f12d84911126cdfa45d15429b2c024f0d93c694f6ee5e77e8
7
- data.tar.gz: 582032e633830bb9e5867ae6883545a4ba5fd0ece16556a542f9de3513a5c0853f4ce7a121ad4af3d3ea6bc901ff136abc9f77f12eaa37789d64fa77bd11a750
6
+ metadata.gz: 0bb2499ecf945fb2586918f5c3e57cdda5e6ce2f143eb4b6d1d674370412ccee6f6e8fe5d992705ac4b0b28de88465ebe7e140fad1a2c71f691982ebe363c4ce
7
+ data.tar.gz: f255c179b502557624bfcfc81bc06743c13898bef9d7b5225e4519e097fbdfc7b88af2b2729088883747cb3a123f1a51fff521618527e68e7e77ca494212be32
data/README.md CHANGED
@@ -1,6 +1,25 @@
1
1
  # Subflag Rails
2
2
 
3
- Typed feature flags for Rails. Booleans, strings, numbers, and JSON — all targetable by user.
3
+ Typed feature flags for Rails. Booleans, strings, numbers, and JSON — with pluggable backends.
4
+
5
+ [Subflag](https://subflag.com)
6
+
7
+ ## Backends
8
+
9
+ Choose where your flags live:
10
+
11
+ | Backend | Use Case | Flags Stored In |
12
+ |---------|----------|-----------------|
13
+ | `:subflag` | Production with dashboard, environments, targeting | Subflag Cloud |
14
+ | `:active_record` | Self-hosted, no external dependencies | Your database |
15
+ | `:memory` | Testing and development | In-memory hash |
16
+
17
+ **Same API regardless of backend:**
18
+
19
+ ```ruby
20
+ subflag_enabled?(:new_checkout) # Works with any backend
21
+ subflag_value(:max_projects, default: 3) # Works with any backend
22
+ ```
4
23
 
5
24
  ## Installation
6
25
 
@@ -8,9 +27,14 @@ Add to your Gemfile:
8
27
 
9
28
  ```ruby
10
29
  gem 'subflag-rails'
30
+
31
+ # If using Subflag Cloud (backend: :subflag), also add:
32
+ gem 'subflag-openfeature-provider'
11
33
  ```
12
34
 
13
- Run the generator:
35
+ ### Option 1: Subflag Cloud (Default)
36
+
37
+ Dashboard, environments, percentage rollouts, and user targeting.
14
38
 
15
39
  ```bash
16
40
  rails generate subflag:install
@@ -29,6 +53,38 @@ subflag:
29
53
 
30
54
  Or set the `SUBFLAG_API_KEY` environment variable.
31
55
 
56
+ ### Option 2: ActiveRecord (Self-Hosted)
57
+
58
+ Flags stored in your database. No external dependencies.
59
+
60
+ ```bash
61
+ rails generate subflag:install --backend=active_record
62
+ rails db:migrate
63
+ ```
64
+
65
+ Create flags directly:
66
+
67
+ ```ruby
68
+ Subflag::Rails::Flag.create!(key: "new-checkout", value: "true", value_type: "boolean")
69
+ Subflag::Rails::Flag.create!(key: "max-projects", value: "100", value_type: "integer")
70
+ Subflag::Rails::Flag.create!(key: "welcome-message", value: "Hello!", value_type: "string")
71
+ ```
72
+
73
+ ### Option 3: Memory (Testing)
74
+
75
+ In-memory flags for tests and local development.
76
+
77
+ ```bash
78
+ rails generate subflag:install --backend=memory
79
+ ```
80
+
81
+ Set flags programmatically:
82
+
83
+ ```ruby
84
+ Subflag::Rails.provider.set(:new_checkout, true)
85
+ Subflag::Rails.provider.set(:max_projects, 100)
86
+ ```
87
+
32
88
  ## Usage
33
89
 
34
90
  ### Controllers & Views
@@ -159,42 +215,6 @@ In Ruby, use underscores — they're automatically converted to dashes:
159
215
  subflag_enabled?(:new_checkout) # looks up "new-checkout"
160
216
  ```
161
217
 
162
- ## Testing
163
-
164
- Stub flags in your tests:
165
-
166
- ```ruby
167
- # spec/rails_helper.rb (RSpec)
168
- require "subflag/rails/test_helpers"
169
- RSpec.configure do |config|
170
- config.include Subflag::Rails::TestHelpers
171
- end
172
-
173
- # test/test_helper.rb (Minitest)
174
- require "subflag/rails/test_helpers"
175
- class ActiveSupport::TestCase
176
- include Subflag::Rails::TestHelpers
177
- end
178
- ```
179
-
180
- ```ruby
181
- # In your specs/tests
182
- it "shows new checkout when enabled" do
183
- stub_subflag(:new_checkout, true)
184
- stub_subflag(:max_projects, 100)
185
-
186
- visit checkout_path
187
- expect(page).to have_content("New Checkout")
188
- end
189
-
190
- # Stub multiple at once
191
- stub_subflags(
192
- new_checkout: true,
193
- max_projects: 100,
194
- headline: "Welcome!"
195
- )
196
- ```
197
-
198
218
  ## Request Caching
199
219
 
200
220
  Enable per-request caching to avoid multiple API calls for the same flag:
@@ -214,6 +234,25 @@ subflag_enabled?(:new_checkout) # Cache hit
214
234
  subflag_enabled?(:new_checkout) # Cache hit
215
235
  ```
216
236
 
237
+ ## Cross-Request Caching
238
+
239
+ By default, prefetched flags are only cached for the current request. To cache across multiple requests using `Rails.cache`, set a TTL:
240
+
241
+ ```ruby
242
+ # config/initializers/subflag.rb
243
+ Subflag::Rails.configure do |config|
244
+ config.api_key = Rails.application.credentials.subflag_api_key
245
+ config.cache_ttl = 30.seconds # Cache flags in Rails.cache for 30 seconds
246
+ end
247
+ ```
248
+
249
+ With `cache_ttl` set:
250
+ - First request fetches from API and stores in `Rails.cache`
251
+ - Subsequent requests (within TTL) read from `Rails.cache` — no API call
252
+ - After TTL expires, next request fetches fresh data
253
+
254
+ This significantly reduces API load for high-traffic applications. Choose a TTL that balances freshness with performance — 30 seconds is a good starting point.
255
+
217
256
  ## Bulk Flag Evaluation (Prefetch)
218
257
 
219
258
  For optimal performance, prefetch all flags for a user in a single API call. This is especially useful when your page checks multiple flags:
@@ -263,25 +302,6 @@ subflag_prefetch(admin_user)
263
302
  subflag_prefetch(current_user, context: { device: "mobile" })
264
303
  ```
265
304
 
266
- ### Cross-Request Caching
267
-
268
- By default, prefetched flags are only cached for the current request. To cache across multiple requests using `Rails.cache`, set a TTL:
269
-
270
- ```ruby
271
- # config/initializers/subflag.rb
272
- Subflag::Rails.configure do |config|
273
- config.api_key = Rails.application.credentials.subflag_api_key
274
- config.cache_ttl = 30.seconds # Cache flags in Rails.cache for 30 seconds
275
- end
276
- ```
277
-
278
- With `cache_ttl` set:
279
- - First request fetches from API and stores in `Rails.cache`
280
- - Subsequent requests (within TTL) read from `Rails.cache` — no API call
281
- - After TTL expires, next request fetches fresh data
282
-
283
- This significantly reduces API load for high-traffic applications. Choose a TTL that balances freshness with performance — 30 seconds is a good starting point.
284
-
285
305
  ### Direct API
286
306
 
287
307
  You can also use the module method directly:
@@ -296,13 +316,16 @@ Subflag::Rails.prefetch_flags(user: current_user)
296
316
 
297
317
  ```ruby
298
318
  Subflag::Rails.configure do |config|
299
- # API key (auto-loaded from credentials/ENV)
319
+ # Backend: :subflag (cloud), :active_record (self-hosted), :memory (testing)
320
+ config.backend = :subflag
321
+
322
+ # API key - required for :subflag backend
300
323
  config.api_key = "sdk-production-..."
301
324
 
302
325
  # API URL (default: https://api.subflag.com)
303
326
  config.api_url = "https://api.subflag.com"
304
327
 
305
- # Cross-request caching via Rails.cache (optional)
328
+ # Cross-request caching via Rails.cache (optional, :subflag backend only)
306
329
  # When set, prefetched flags are cached for this duration
307
330
  config.cache_ttl = 30.seconds
308
331
 
@@ -310,13 +333,72 @@ Subflag::Rails.configure do |config|
310
333
  config.logging_enabled = Rails.env.development?
311
334
  config.log_level = :debug # :debug, :info, :warn
312
335
 
313
- # User context
336
+ # User context - works with all backends
314
337
  config.user_context do |user|
315
338
  { targeting_key: user.id.to_s, plan: user.plan }
316
339
  end
317
340
  end
318
341
  ```
319
342
 
343
+ ### ActiveRecord Flag Model
344
+
345
+ When using `backend: :active_record`, flags are stored in the `subflag_flags` table:
346
+
347
+ | Column | Type | Description |
348
+ |--------|------|-------------|
349
+ | `key` | string | Flag name (lowercase, dashes, e.g., `new-checkout`) |
350
+ | `value` | text | The flag value as a string |
351
+ | `value_type` | string | Type: `boolean`, `string`, `integer`, `float`, `object` |
352
+ | `enabled` | boolean | Whether the flag is active (default: true) |
353
+ | `description` | text | Optional description |
354
+
355
+ ```ruby
356
+ # Create flags
357
+ Subflag::Rails::Flag.create!(key: "max-projects", value: "100", value_type: "integer")
358
+
359
+ # Query flags
360
+ Subflag::Rails::Flag.enabled.find_each { |f| puts "#{f.key}: #{f.typed_value}" }
361
+
362
+ # Disable a flag
363
+ Subflag::Rails::Flag.find_by(key: "new-checkout")&.update!(enabled: false)
364
+ ```
365
+
366
+ ## Testing
367
+
368
+ Stub flags in your tests:
369
+
370
+ ```ruby
371
+ # spec/rails_helper.rb (RSpec)
372
+ require "subflag/rails/test_helpers"
373
+ RSpec.configure do |config|
374
+ config.include Subflag::Rails::TestHelpers
375
+ end
376
+
377
+ # test/test_helper.rb (Minitest)
378
+ require "subflag/rails/test_helpers"
379
+ class ActiveSupport::TestCase
380
+ include Subflag::Rails::TestHelpers
381
+ end
382
+ ```
383
+
384
+ ```ruby
385
+ # In your specs/tests
386
+ it "shows new checkout when enabled" do
387
+ stub_subflag(:new_checkout, true)
388
+ stub_subflag(:max_projects, 100)
389
+
390
+ visit checkout_path
391
+ expect(page).to have_content("New Checkout")
392
+ end
393
+
394
+ # Stub multiple at once
395
+ stub_subflags(
396
+ new_checkout: true,
397
+ max_projects: 100,
398
+ headline: "Welcome!"
399
+ )
400
+ ```
401
+
320
402
  ## Documentation
321
403
 
322
404
  - [Subflag Docs](https://docs.subflag.com)
@@ -7,49 +7,113 @@ module Subflag
7
7
  # Generator for setting up Subflag in a Rails application
8
8
  #
9
9
  # Usage:
10
- # rails generate subflag:install
10
+ # rails generate subflag:install # Default: Subflag Cloud
11
+ # rails generate subflag:install --backend=subflag # Explicit: Subflag Cloud
12
+ # rails generate subflag:install --backend=active_record # Self-hosted DB
13
+ # rails generate subflag:install --backend=memory # Testing only
11
14
  #
12
15
  class InstallGenerator < ::Rails::Generators::Base
16
+ include ::Rails::Generators::Migration if defined?(::Rails::Generators::Migration)
17
+
13
18
  source_root File.expand_path("templates", __dir__)
14
19
 
15
- desc "Creates a Subflag initializer and provides setup instructions"
20
+ desc "Creates a Subflag initializer and optionally a migration for ActiveRecord backend"
21
+
22
+ class_option :backend, type: :string, default: "subflag",
23
+ desc: "Backend to use: subflag (cloud), active_record (self-hosted), or memory (testing)"
24
+
25
+ def self.next_migration_number(dirname)
26
+ if defined?(::ActiveRecord::Generators::Base)
27
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
28
+ else
29
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
30
+ end
31
+ end
16
32
 
17
33
  def create_initializer
18
- template "initializer.rb", "config/initializers/subflag.rb"
34
+ template "initializer.rb.tt", "config/initializers/subflag.rb"
35
+ end
36
+
37
+ def create_migration
38
+ return unless options[:backend] == "active_record"
39
+
40
+ migration_template "create_subflag_flags.rb.tt",
41
+ "db/migrate/create_subflag_flags.rb"
19
42
  end
20
43
 
21
44
  def show_instructions
22
45
  say ""
23
- say "Subflag installed!", :green
24
- say ""
25
- say "Next steps:"
26
- say ""
27
- say "1. Add your API key to Rails credentials:"
28
- say " $ rails credentials:edit"
29
- say ""
30
- say " subflag:"
31
- say " api_key: sdk-production-your-key-here"
32
- say ""
33
- say " Or set SUBFLAG_API_KEY environment variable."
34
- say ""
35
- say "2. Configure user context in config/initializers/subflag.rb"
36
- say ""
37
- say "3. Use flags in your code:"
38
- say ""
39
- say " # Controller (auto-scoped to current_user)"
40
- say " if subflag_enabled?(:new_checkout)"
41
- say " # ..."
42
- say " end"
43
- say ""
44
- say " max = subflag_value(:max_projects, default: 3)"
45
- say ""
46
- say " # View"
47
- say " <% if subflag_enabled?(:new_checkout) %>"
48
- say " <%= render 'new_checkout' %>"
49
- say " <% end %>"
50
- say ""
51
- say "Docs: https://docs.subflag.com/rails"
52
- say ""
46
+
47
+ case options[:backend]
48
+ when "active_record"
49
+ say "Subflag installed with ActiveRecord backend!", :green
50
+ say ""
51
+ say "Next steps:"
52
+ say ""
53
+ say "1. Run the migration:"
54
+ say " $ rails db:migrate"
55
+ say ""
56
+ say "2. Create your first flag:"
57
+ say ""
58
+ say " Subflag::Rails::Flag.create!("
59
+ say " key: 'new-checkout',"
60
+ say " value: 'true',"
61
+ say " value_type: 'boolean'"
62
+ say " )"
63
+ say ""
64
+ say "3. Use flags in your code:"
65
+ say ""
66
+ say " if subflag_enabled?(:new_checkout)"
67
+ say " # ..."
68
+ say " end"
69
+ say ""
70
+ say "When you're ready for a dashboard, environments, and user targeting:"
71
+ say " https://subflag.com", :yellow
72
+ say ""
73
+
74
+ when "memory"
75
+ say "Subflag installed with Memory backend!", :green
76
+ say ""
77
+ say "Note: Memory backend is for testing only. Flags reset on restart."
78
+ say ""
79
+ say "Set flags in your tests or initializer:"
80
+ say ""
81
+ say " Subflag::Rails.provider.set(:new_checkout, true)"
82
+ say " Subflag::Rails.provider.set(:max_projects, 100)"
83
+ say ""
84
+ say "Use flags:"
85
+ say ""
86
+ say " subflag_enabled?(:new_checkout) # => true"
87
+ say " subflag_value(:max_projects, default: 3) # => 100"
88
+ say ""
89
+
90
+ else # subflag (cloud)
91
+ say "Subflag installed!", :green
92
+ say ""
93
+ say "Next steps:"
94
+ say ""
95
+ say "1. Add your API key to Rails credentials:"
96
+ say " $ rails credentials:edit"
97
+ say ""
98
+ say " subflag:"
99
+ say " api_key: sdk-production-your-key-here"
100
+ say ""
101
+ say " Or set SUBFLAG_API_KEY environment variable."
102
+ say ""
103
+ say "2. Configure user context in config/initializers/subflag.rb"
104
+ say ""
105
+ say "3. Use flags in your code:"
106
+ say ""
107
+ say " # Controller (auto-scoped to current_user)"
108
+ say " if subflag_enabled?(:new_checkout)"
109
+ say " # ..."
110
+ say " end"
111
+ say ""
112
+ say " max = subflag_value(:max_projects, default: 3)"
113
+ say ""
114
+ say "Docs: https://docs.subflag.com/rails"
115
+ say ""
116
+ end
53
117
  end
54
118
  end
55
119
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSubflagFlags < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ create_table :subflag_flags do |t|
6
+ t.string :key, null: false
7
+ t.string :value_type, null: false, default: "boolean"
8
+ t.text :value, null: false
9
+ t.boolean :enabled, null: false, default: true
10
+ t.text :description
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :subflag_flags, :key, unique: true
16
+ end
17
+ end
@@ -2,17 +2,31 @@
2
2
 
3
3
  # Subflag configuration
4
4
  #
5
+ <% if options[:backend] == "subflag" -%>
5
6
  # API key is automatically loaded from:
6
7
  # 1. Rails credentials (subflag.api_key or subflag_api_key)
7
8
  # 2. SUBFLAG_API_KEY environment variable
9
+ <% elsif options[:backend] == "active_record" -%>
10
+ # Using ActiveRecord backend - flags stored in subflag_flags table
11
+ <% else -%>
12
+ # Using Memory backend - flags stored in memory (testing only)
13
+ <% end -%>
8
14
 
9
15
  Subflag::Rails.configure do |config|
10
- # Uncomment to manually set API key
11
- # config.api_key = Rails.application.credentials.dig(:subflag, :api_key)
16
+ # Backend: :subflag (cloud), :active_record (self-hosted), :memory (testing)
17
+ config.backend = :<%= options[:backend] %>
18
+ <% if options[:backend] == "subflag" -%>
19
+
20
+ # Your Subflag API key
21
+ # Get one at https://subflag.com
22
+ config.api_key = ENV["SUBFLAG_API_KEY"] || Rails.application.credentials.dig(:subflag, :api_key)
23
+ <% end -%>
24
+ <% if options[:backend] != "memory" -%>
12
25
 
13
26
  # Enable logging in development
14
27
  config.logging_enabled = Rails.env.development?
15
28
  config.log_level = :debug
29
+ <% end -%>
16
30
 
17
31
  # Configure user context for targeting
18
32
  # This enables per-user flag values (e.g., different limits by plan)
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ module Backends
6
+ # Provider that reads flags from your Rails database
7
+ #
8
+ # Stores flags in a `subflag_flags` table with typed values.
9
+ # Perfect for teams who want self-hosted feature flags without external dependencies.
10
+ #
11
+ # @example
12
+ # Subflag::Rails.configure do |config|
13
+ # config.backend = :active_record
14
+ # end
15
+ #
16
+ # # Create a flag
17
+ # Subflag::Rails::Flag.create!(
18
+ # key: "max-projects",
19
+ # value: "100",
20
+ # value_type: "integer",
21
+ # enabled: true
22
+ # )
23
+ #
24
+ # # Use it
25
+ # subflag_value(:max_projects, default: 3) # => 100
26
+ #
27
+ class ActiveRecordProvider
28
+ def metadata
29
+ { name: "Subflag ActiveRecord Provider" }
30
+ end
31
+
32
+ def init; end
33
+ def shutdown; end
34
+
35
+ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
36
+ resolve(flag_key, default_value, :boolean)
37
+ end
38
+
39
+ def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
40
+ resolve(flag_key, default_value, :string)
41
+ end
42
+
43
+ def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
44
+ resolve(flag_key, default_value, :number)
45
+ end
46
+
47
+ def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
48
+ resolve(flag_key, default_value, :integer)
49
+ end
50
+
51
+ def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
52
+ resolve(flag_key, default_value, :float)
53
+ end
54
+
55
+ def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
56
+ resolve(flag_key, default_value, :object)
57
+ end
58
+
59
+ private
60
+
61
+ def resolve(flag_key, default_value, expected_type)
62
+ flag = Subflag::Rails::Flag.find_by(key: flag_key)
63
+
64
+ unless flag&.enabled?
65
+ return resolution(default_value, reason: :default)
66
+ end
67
+
68
+ value = flag.typed_value(expected_type)
69
+ resolution(value, reason: :static, variant: "default")
70
+ end
71
+
72
+ def resolution(value, reason:, variant: nil)
73
+ OpenFeature::SDK::Provider::ResolutionDetails.new(
74
+ value: value,
75
+ reason: reason,
76
+ variant: variant
77
+ )
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ module Backends
6
+ # In-memory provider for testing and development
7
+ #
8
+ # Flags are stored in a hash and reset when the process restarts.
9
+ # Useful for unit tests and local development without external dependencies.
10
+ #
11
+ # @example In tests
12
+ # Subflag::Rails.configure do |config|
13
+ # config.backend = :memory
14
+ # end
15
+ #
16
+ # # Set flags directly
17
+ # Subflag::Rails.provider.set(:new_checkout, true)
18
+ # Subflag::Rails.provider.set(:max_projects, 100)
19
+ #
20
+ # # Use them
21
+ # subflag_enabled?(:new_checkout) # => true
22
+ #
23
+ class MemoryProvider
24
+ def initialize
25
+ @flags = {}
26
+ end
27
+
28
+ def metadata
29
+ { name: "Subflag Memory Provider" }
30
+ end
31
+
32
+ def init; end
33
+ def shutdown; end
34
+
35
+ # Set a flag value programmatically
36
+ #
37
+ # @param key [String, Symbol] The flag key (underscores converted to dashes)
38
+ # @param value [Object] The flag value
39
+ # @param enabled [Boolean] Whether the flag is enabled (default: true)
40
+ def set(key, value, enabled: true)
41
+ @flags[normalize_key(key)] = { value: value, enabled: enabled }
42
+ end
43
+
44
+ # Clear all flags
45
+ def clear
46
+ @flags.clear
47
+ end
48
+
49
+ # Get all flags (for debugging)
50
+ def all
51
+ @flags.dup
52
+ end
53
+
54
+ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
55
+ resolve(flag_key, default_value)
56
+ end
57
+
58
+ def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
59
+ resolve(flag_key, default_value)
60
+ end
61
+
62
+ def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
63
+ resolve(flag_key, default_value)
64
+ end
65
+
66
+ def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
67
+ resolve(flag_key, default_value)
68
+ end
69
+
70
+ def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
71
+ resolve(flag_key, default_value)
72
+ end
73
+
74
+ def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
75
+ resolve(flag_key, default_value)
76
+ end
77
+
78
+ private
79
+
80
+ def normalize_key(key)
81
+ key.to_s.tr("_", "-")
82
+ end
83
+
84
+ def resolve(flag_key, default_value)
85
+ flag = @flags[flag_key.to_s]
86
+
87
+ unless flag && flag[:enabled]
88
+ return resolution(default_value, reason: :default)
89
+ end
90
+
91
+ resolution(flag[:value], reason: :static, variant: "default")
92
+ end
93
+
94
+ def resolution(value, reason:, variant: nil)
95
+ OpenFeature::SDK::Provider::ResolutionDetails.new(
96
+ value: value,
97
+ reason: reason,
98
+ variant: variant
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ module Backends
6
+ # Provider wrapper for Subflag Cloud SaaS
7
+ #
8
+ # Delegates to the standalone subflag-openfeature-provider gem.
9
+ # This is the default backend when using Subflag::Rails.
10
+ #
11
+ # @example
12
+ # Subflag::Rails.configure do |config|
13
+ # config.backend = :subflag
14
+ # config.api_key = "sdk-production-..."
15
+ # end
16
+ #
17
+ class SubflagProvider
18
+ def initialize(api_key:, api_url:)
19
+ require "subflag"
20
+ @provider = ::Subflag::Provider.new(api_key: api_key, api_url: api_url)
21
+ end
22
+
23
+ def metadata
24
+ @provider.metadata
25
+ end
26
+
27
+ def init
28
+ @provider.init
29
+ end
30
+
31
+ def shutdown
32
+ @provider.shutdown
33
+ end
34
+
35
+ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
36
+ @provider.fetch_boolean_value(
37
+ flag_key: flag_key,
38
+ default_value: default_value,
39
+ evaluation_context: evaluation_context
40
+ )
41
+ end
42
+
43
+ def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
44
+ @provider.fetch_string_value(
45
+ flag_key: flag_key,
46
+ default_value: default_value,
47
+ evaluation_context: evaluation_context
48
+ )
49
+ end
50
+
51
+ def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
52
+ @provider.fetch_number_value(
53
+ flag_key: flag_key,
54
+ default_value: default_value,
55
+ evaluation_context: evaluation_context
56
+ )
57
+ end
58
+
59
+ def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
60
+ @provider.fetch_integer_value(
61
+ flag_key: flag_key,
62
+ default_value: default_value,
63
+ evaluation_context: evaluation_context
64
+ )
65
+ end
66
+
67
+ def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
68
+ @provider.fetch_float_value(
69
+ flag_key: flag_key,
70
+ default_value: default_value,
71
+ evaluation_context: evaluation_context
72
+ )
73
+ end
74
+
75
+ def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
76
+ @provider.fetch_object_value(
77
+ flag_key: flag_key,
78
+ default_value: default_value,
79
+ evaluation_context: evaluation_context
80
+ )
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -2,6 +2,10 @@
2
2
 
3
3
  module Subflag
4
4
  module Rails
5
+ # Lightweight struct for caching prefetched flag results
6
+ # Used by ActiveRecord and Memory backends where we don't have Subflag::EvaluationResult
7
+ PrefetchedFlag = Struct.new(:flag_key, :value, :reason, :variant, keyword_init: true)
8
+
5
9
  # Client for evaluating feature flags
6
10
  #
7
11
  # This is the low-level client used by FlagAccessor.
@@ -10,8 +14,11 @@ module Subflag
10
14
  class Client
11
15
  # Prefetch all flags for a user/context
12
16
  #
13
- # Fetches all flags in a single API call and caches them.
14
- # Subsequent flag lookups will use the cached values.
17
+ # Fetches all flags and caches them for subsequent lookups.
18
+ # Behavior varies by backend:
19
+ # - :subflag — Single API call to fetch all flags
20
+ # - :active_record — Single DB query to load all enabled flags
21
+ # - :memory — No-op (flags already in memory)
15
22
  #
16
23
  # @param user [Object, nil] The user object for targeting
17
24
  # @param context [Hash, nil] Additional context attributes
@@ -23,16 +30,17 @@ module Subflag
23
30
  # subflag_enabled?(:new_feature)
24
31
  #
25
32
  def prefetch_all(user: nil, context: nil)
26
- ctx = ContextBuilder.build(user: user, context: context)
27
- context_hash = ctx ? ctx.hash : "no_context"
28
-
29
- # Use Rails.cache for cross-request caching if enabled
30
- if configuration.rails_cache_enabled?
31
- return prefetch_with_rails_cache(ctx, context_hash)
33
+ case configuration.backend
34
+ when :subflag
35
+ prefetch_from_subflag_api(user: user, context: context)
36
+ when :active_record
37
+ prefetch_from_active_record(user: user, context: context)
38
+ when :memory
39
+ # Already in memory, nothing to prefetch
40
+ []
41
+ else
42
+ []
32
43
  end
33
-
34
- # Otherwise fetch directly from API (per-request cache only)
35
- prefetch_from_api(ctx, context_hash)
36
44
  end
37
45
 
38
46
  # Check if a boolean flag is enabled
@@ -121,6 +129,20 @@ module Subflag
121
129
 
122
130
  private
123
131
 
132
+ # Prefetch flags from Subflag Cloud API
133
+ def prefetch_from_subflag_api(user:, context:)
134
+ ctx = ContextBuilder.build(user: user, context: context)
135
+ context_hash = ctx ? ctx.hash : "no_context"
136
+
137
+ # Use Rails.cache for cross-request caching if enabled
138
+ if configuration.rails_cache_enabled?
139
+ return prefetch_with_rails_cache(ctx, context_hash)
140
+ end
141
+
142
+ # Otherwise fetch directly from API (per-request cache only)
143
+ prefetch_from_api(ctx, context_hash)
144
+ end
145
+
124
146
  # Fetch flags from API and populate RequestCache
125
147
  def prefetch_from_api(ctx, context_hash)
126
148
  subflag_context = build_subflag_context(ctx)
@@ -151,7 +173,7 @@ module Subflag
151
173
  cached_data
152
174
  end
153
175
 
154
- # Populate RequestCache from cached hash data
176
+ # Populate RequestCache from cached hash data (Subflag API)
155
177
  def populate_request_cache_from_data(data_array, context_hash)
156
178
  return unless RequestCache.enabled?
157
179
 
@@ -161,6 +183,30 @@ module Subflag
161
183
  end
162
184
  end
163
185
 
186
+ # Prefetch flags from ActiveRecord database
187
+ # Loads all enabled flags in one query and caches their values
188
+ def prefetch_from_active_record(user:, context:)
189
+ return [] unless RequestCache.enabled?
190
+
191
+ ctx = ContextBuilder.build(user: user, context: context)
192
+ context_hash = ctx ? ctx.hash : "no_context"
193
+
194
+ prefetched = []
195
+
196
+ Subflag::Rails::Flag.enabled.find_each do |flag|
197
+ prefetch_key = "subflag:prefetch:#{flag.key}:#{context_hash}"
198
+ RequestCache.current_cache[prefetch_key] = PrefetchedFlag.new(
199
+ flag_key: flag.key,
200
+ value: flag.typed_value,
201
+ reason: "STATIC",
202
+ variant: "default"
203
+ )
204
+ prefetched << { flag_key: flag.key, value: flag.typed_value }
205
+ end
206
+
207
+ prefetched
208
+ end
209
+
164
210
  def build_cache_key(flag_key, ctx, type)
165
211
  context_hash = ctx ? ctx.hash : "no_context"
166
212
  "subflag:#{flag_key}:#{context_hash}:#{type}"
@@ -23,6 +23,14 @@ module Subflag
23
23
  # end
24
24
  #
25
25
  class Configuration
26
+ VALID_BACKENDS = %i[subflag active_record memory].freeze
27
+
28
+ # @return [Symbol] Backend to use (:subflag, :active_record, :memory)
29
+ # - :subflag — Subflag Cloud SaaS (default)
30
+ # - :active_record — Self-hosted, flags stored in your database
31
+ # - :memory — In-memory store for testing
32
+ attr_reader :backend
33
+
26
34
  # @return [String, nil] The Subflag API key
27
35
  attr_accessor :api_key
28
36
 
@@ -41,6 +49,7 @@ module Subflag
41
49
  attr_accessor :cache_ttl
42
50
 
43
51
  def initialize
52
+ @backend = :subflag
44
53
  @api_key = nil
45
54
  @api_url = "https://api.subflag.com"
46
55
  @user_context_block = nil
@@ -49,6 +58,19 @@ module Subflag
49
58
  @cache_ttl = nil
50
59
  end
51
60
 
61
+ # Set the backend with validation
62
+ #
63
+ # @param value [Symbol] The backend to use
64
+ # @raise [ArgumentError] If the backend is invalid
65
+ def backend=(value)
66
+ value = value.to_sym
67
+ unless VALID_BACKENDS.include?(value)
68
+ raise ArgumentError, "Invalid backend: #{value}. Use one of: #{VALID_BACKENDS.join(', ')}"
69
+ end
70
+
71
+ @backend = value
72
+ end
73
+
52
74
  # Check if cross-request caching via Rails.cache is enabled
53
75
  #
54
76
  # @return [Boolean]
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ # ActiveRecord model for storing feature flags in your database
6
+ #
7
+ # @example Create a boolean flag
8
+ # Subflag::Rails::Flag.create!(
9
+ # key: "new-checkout",
10
+ # value: "true",
11
+ # value_type: "boolean"
12
+ # )
13
+ #
14
+ # @example Create an integer flag
15
+ # Subflag::Rails::Flag.create!(
16
+ # key: "max-projects",
17
+ # value: "100",
18
+ # value_type: "integer"
19
+ # )
20
+ #
21
+ # @example Create a JSON object flag
22
+ # Subflag::Rails::Flag.create!(
23
+ # key: "feature-limits",
24
+ # value: '{"max_items": 10, "max_users": 5}',
25
+ # value_type: "object"
26
+ # )
27
+ #
28
+ class Flag < ::ActiveRecord::Base
29
+ self.table_name = "subflag_flags"
30
+
31
+ VALUE_TYPES = %w[boolean string integer float object].freeze
32
+
33
+ validates :key, presence: true,
34
+ uniqueness: true,
35
+ format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and dashes" }
36
+ validates :value_type, inclusion: { in: VALUE_TYPES }
37
+ validates :value, presence: true
38
+
39
+ scope :enabled, -> { where(enabled: true) }
40
+
41
+ # Get the flag value cast to its declared type
42
+ #
43
+ # @param expected_type [Symbol, String, nil] Override the value_type for casting
44
+ # @return [Object] The typed value
45
+ def typed_value(expected_type = nil)
46
+ type = expected_type&.to_s || value_type
47
+
48
+ case type.to_s
49
+ when "boolean"
50
+ ActiveModel::Type::Boolean.new.cast(value)
51
+ when "string"
52
+ value.to_s
53
+ when "integer"
54
+ value.to_i
55
+ when "float", "number"
56
+ value.to_f
57
+ when "object"
58
+ value.is_a?(Hash) ? value : JSON.parse(value)
59
+ else
60
+ value
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Subflag
4
4
  module Rails
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
data/lib/subflag/rails.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open_feature/sdk"
4
- require "subflag"
5
4
 
6
5
  require_relative "rails/version"
7
6
  require_relative "rails/configuration"
@@ -27,14 +26,25 @@ module Subflag
27
26
 
28
27
  # Configure Subflag for Rails
29
28
  #
30
- # @example
29
+ # @example Using Subflag Cloud (SaaS)
31
30
  # Subflag::Rails.configure do |config|
31
+ # config.backend = :subflag
32
32
  # config.api_key = Rails.application.credentials.subflag_api_key
33
33
  # config.user_context do |user|
34
34
  # { targeting_key: user.id.to_s, email: user.email, plan: user.plan }
35
35
  # end
36
36
  # end
37
37
  #
38
+ # @example Using ActiveRecord (self-hosted)
39
+ # Subflag::Rails.configure do |config|
40
+ # config.backend = :active_record
41
+ # end
42
+ #
43
+ # @example Using Memory (testing)
44
+ # Subflag::Rails.configure do |config|
45
+ # config.backend = :memory
46
+ # end
47
+ #
38
48
  # @yield [Configuration]
39
49
  def configure
40
50
  yield(configuration)
@@ -48,6 +58,16 @@ module Subflag
48
58
  @client ||= Client.new
49
59
  end
50
60
 
61
+ # Access the current provider instance
62
+ #
63
+ # Useful for the Memory backend where you can set flags directly:
64
+ # Subflag::Rails.provider.set(:my_flag, true)
65
+ #
66
+ # @return [Object] The current OpenFeature provider
67
+ def provider
68
+ @provider
69
+ end
70
+
51
71
  # Prefetch all flags for a user/context in a single API call
52
72
  #
53
73
  # Call this early in a request to fetch all flags at once.
@@ -72,21 +92,52 @@ module Subflag
72
92
  def reset!
73
93
  @configuration = Configuration.new
74
94
  @client = nil
95
+ @provider = nil
75
96
  end
76
97
 
77
98
  private
78
99
 
79
100
  def setup_provider
80
- return unless configuration.api_key
101
+ @provider = build_provider
102
+ return unless @provider
81
103
 
82
- provider = ::Subflag::Provider.new(
104
+ OpenFeature::SDK.configure do |config|
105
+ config.set_provider(@provider)
106
+ end
107
+ end
108
+
109
+ def build_provider
110
+ case configuration.backend
111
+ when :subflag
112
+ build_subflag_provider
113
+ when :active_record
114
+ build_active_record_provider
115
+ when :memory
116
+ build_memory_provider
117
+ else
118
+ raise ArgumentError, "Unknown backend: #{configuration.backend}. Use :subflag, :active_record, or :memory"
119
+ end
120
+ end
121
+
122
+ def build_subflag_provider
123
+ return nil unless configuration.api_key
124
+
125
+ require_relative "rails/backends/subflag_provider"
126
+ Backends::SubflagProvider.new(
83
127
  api_key: configuration.api_key,
84
128
  api_url: configuration.api_url
85
129
  )
130
+ end
86
131
 
87
- OpenFeature::SDK.configure do |config|
88
- config.set_provider(provider)
89
- end
132
+ def build_active_record_provider
133
+ require_relative "rails/backends/active_record_provider"
134
+ require_relative "rails/models/flag"
135
+ Backends::ActiveRecordProvider.new
136
+ end
137
+
138
+ def build_memory_provider
139
+ require_relative "rails/backends/memory_provider"
140
+ Backends::MemoryProvider.new
90
141
  end
91
142
  end
92
143
  end
metadata CHANGED
@@ -1,22 +1,22 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: subflag-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Subflag
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-12-08 00:00:00.000000000 Z
11
+ date: 2025-12-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: subflag-openfeature-provider
14
+ name: openfeature-sdk
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 0.3.1
19
+ version: '0.3'
20
20
  - - "<"
21
21
  - !ruby/object:Gem::Version
22
22
  version: '1.0'
@@ -26,7 +26,7 @@ dependencies:
26
26
  requirements:
27
27
  - - ">="
28
28
  - !ruby/object:Gem::Version
29
- version: 0.3.1
29
+ version: '0.3'
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: '1.0'
@@ -170,9 +170,9 @@ dependencies:
170
170
  - - ">="
171
171
  - !ruby/object:Gem::Version
172
172
  version: '6.1'
173
- description: Rails integration for Subflag feature flags. Get typed values (boolean,
174
- string, integer, double, object) with user targeting. Includes generators, view
175
- helpers, and ActiveRecord context integration.
173
+ description: Feature flags for Rails with pluggable backends. Use Subflag Cloud (SaaS),
174
+ ActiveRecord (self-hosted), or Memory (testing). Get typed values (boolean, string,
175
+ integer, double, object) with the same API regardless of backend.
176
176
  email:
177
177
  - support@subflag.com
178
178
  executables: []
@@ -183,15 +183,20 @@ files:
183
183
  - LICENSE.txt
184
184
  - README.md
185
185
  - lib/generators/subflag/install_generator.rb
186
- - lib/generators/subflag/templates/initializer.rb
186
+ - lib/generators/subflag/templates/create_subflag_flags.rb.tt
187
+ - lib/generators/subflag/templates/initializer.rb.tt
187
188
  - lib/subflag-rails.rb
188
189
  - lib/subflag/rails.rb
190
+ - lib/subflag/rails/backends/active_record_provider.rb
191
+ - lib/subflag/rails/backends/memory_provider.rb
192
+ - lib/subflag/rails/backends/subflag_provider.rb
189
193
  - lib/subflag/rails/client.rb
190
194
  - lib/subflag/rails/configuration.rb
191
195
  - lib/subflag/rails/context_builder.rb
192
196
  - lib/subflag/rails/evaluation_result.rb
193
197
  - lib/subflag/rails/flag_accessor.rb
194
198
  - lib/subflag/rails/helpers.rb
199
+ - lib/subflag/rails/models/flag.rb
195
200
  - lib/subflag/rails/railtie.rb
196
201
  - lib/subflag/rails/request_cache.rb
197
202
  - lib/subflag/rails/test_helpers.rb