subflag-rails 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,27 +2,47 @@
2
2
 
3
3
  module Subflag
4
4
  module Rails
5
- # ActiveRecord model for storing feature flags in your database
5
+ # ActiveRecord model for storing feature flags in your database.
6
6
  #
7
- # @example Create a boolean flag
7
+ # Supports targeting rules to show different values to different users.
8
+ # Perfect for internal testing before wider rollout.
9
+ #
10
+ # @example Simple flag (everyone gets the same value)
8
11
  # Subflag::Rails::Flag.create!(
9
12
  # key: "new-checkout",
10
13
  # value: "true",
11
14
  # value_type: "boolean"
12
15
  # )
13
16
  #
14
- # @example Create an integer flag
17
+ # @example Flag with targeting rules (internal team gets different value)
15
18
  # Subflag::Rails::Flag.create!(
16
- # key: "max-projects",
17
- # value: "100",
18
- # value_type: "integer"
19
+ # key: "new-dashboard",
20
+ # value: "false",
21
+ # value_type: "boolean",
22
+ # targeting_rules: [
23
+ # {
24
+ # "value" => "true",
25
+ # "conditions" => {
26
+ # "type" => "OR",
27
+ # "conditions" => [
28
+ # { "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" },
29
+ # { "attribute" => "role", "operator" => "IN", "value" => ["admin", "developer", "qa"] }
30
+ # ]
31
+ # }
32
+ # }
33
+ # ]
19
34
  # )
20
35
  #
21
- # @example Create a JSON object flag
36
+ # @example Progressive rollout (first match wins)
22
37
  # Subflag::Rails::Flag.create!(
23
- # key: "feature-limits",
24
- # value: '{"max_items": 10, "max_users": 5}',
25
- # value_type: "object"
38
+ # key: "max-projects",
39
+ # value: "5",
40
+ # value_type: "integer",
41
+ # targeting_rules: [
42
+ # { "value" => "1000", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "role", "operator" => "EQUALS", "value" => "admin" }] } },
43
+ # { "value" => "100", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" }] } },
44
+ # { "value" => "25", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "plan", "operator" => "EQUALS", "value" => "pro" }] } }
45
+ # ]
26
46
  # )
27
47
  #
28
48
  class Flag < ::ActiveRecord::Base
@@ -35,29 +55,98 @@ module Subflag
35
55
  format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and dashes" }
36
56
  validates :value_type, inclusion: { in: VALUE_TYPES }
37
57
  validates :value, presence: true
58
+ validate :validate_targeting_rules
38
59
 
39
60
  scope :enabled, -> { where(enabled: true) }
40
61
 
41
- # Get the flag value cast to its declared type
62
+ # Evaluate the flag for a given context
63
+ #
64
+ # Returns the matched rule's value if context matches targeting rules,
65
+ # otherwise returns the default value.
66
+ #
67
+ # @param context [Hash, nil] Evaluation context with user attributes
68
+ # @param expected_type [Symbol, String, nil] Override the value_type for casting
69
+ # @return [Object] The evaluated value, cast to the appropriate type
70
+ def evaluate(context: nil, expected_type: nil)
71
+ rules = parsed_targeting_rules
72
+ raw_value = if rules.present? && context.present?
73
+ matched = TargetingEngine.evaluate(rules, context)
74
+ matched || value
75
+ else
76
+ value
77
+ end
78
+
79
+ cast_value(raw_value, expected_type)
80
+ end
81
+
82
+ # Get the flag's default value cast to its declared type (ignores targeting)
42
83
  #
43
84
  # @param expected_type [Symbol, String, nil] Override the value_type for casting
44
85
  # @return [Object] The typed value
45
86
  def typed_value(expected_type = nil)
87
+ cast_value(value, expected_type)
88
+ end
89
+
90
+ private
91
+
92
+ def cast_value(raw_value, expected_type = nil)
46
93
  type = expected_type&.to_s || value_type
47
94
 
48
95
  case type.to_s
49
96
  when "boolean"
50
- ActiveModel::Type::Boolean.new.cast(value)
97
+ ActiveModel::Type::Boolean.new.cast(raw_value)
51
98
  when "string"
52
- value.to_s
99
+ raw_value.to_s
53
100
  when "integer"
54
- value.to_i
101
+ raw_value.to_i
55
102
  when "float", "number"
56
- value.to_f
103
+ raw_value.to_f
57
104
  when "object"
58
- value.is_a?(Hash) ? value : JSON.parse(value)
105
+ raw_value.is_a?(Hash) ? raw_value : JSON.parse(raw_value)
59
106
  else
60
- value
107
+ raw_value
108
+ end
109
+ end
110
+
111
+ def parsed_targeting_rules
112
+ return nil if targeting_rules.blank?
113
+
114
+ case targeting_rules
115
+ when String
116
+ JSON.parse(targeting_rules)
117
+ when Array
118
+ targeting_rules
119
+ else
120
+ targeting_rules
121
+ end
122
+ end
123
+
124
+ def validate_targeting_rules
125
+ return if targeting_rules.blank?
126
+
127
+ rules = case targeting_rules
128
+ when Array then targeting_rules
129
+ when String
130
+ begin
131
+ JSON.parse(targeting_rules)
132
+ rescue JSON::ParserError
133
+ errors.add(:targeting_rules, "must be valid JSON")
134
+ return
135
+ end
136
+ else
137
+ errors.add(:targeting_rules, "must be an array of rules")
138
+ return
139
+ end
140
+
141
+ rules.each_with_index do |rule, index|
142
+ unless rule.is_a?(Hash)
143
+ errors.add(:targeting_rules, "rule #{index} must be a hash")
144
+ next
145
+ end
146
+
147
+ rule = rule.transform_keys(&:to_s)
148
+ errors.add(:targeting_rules, "rule #{index} must have a 'value' key") unless rule.key?("value")
149
+ errors.add(:targeting_rules, "rule #{index} must have a 'conditions' key") unless rule.key?("conditions")
61
150
  end
62
151
  end
63
152
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "targeting_engine"
4
+
5
+ module Subflag
6
+ module Rails
7
+ # Targeting module for evaluating rules that control who sees what flag values.
8
+ #
9
+ # Rules are stored as JSON in the database and evaluated at runtime.
10
+ # Use targeting to roll out features to internal teams before wider release.
11
+ #
12
+ # ## Rule Format
13
+ #
14
+ # Rules are an array of objects, each with a `value` and `conditions`:
15
+ #
16
+ # [
17
+ # {
18
+ # "value" => "true",
19
+ # "conditions" => {
20
+ # "type" => "OR",
21
+ # "conditions" => [
22
+ # { "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" },
23
+ # { "attribute" => "role", "operator" => "IN", "value" => ["admin", "qa"] }
24
+ # ]
25
+ # }
26
+ # }
27
+ # ]
28
+ #
29
+ # ## Supported Operators
30
+ #
31
+ # - EQUALS, NOT_EQUALS - exact match
32
+ # - IN, NOT_IN - list membership
33
+ # - CONTAINS, NOT_CONTAINS - substring match
34
+ # - STARTS_WITH, ENDS_WITH - prefix/suffix match
35
+ # - GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL - numeric
36
+ # - MATCHES - regex match
37
+ #
38
+ # ## Evaluation
39
+ #
40
+ # Rules are evaluated in order. First match wins.
41
+ # If no rules match, the flag's default value is returned.
42
+ #
43
+ # TODO: Add admin UI for managing targeting rules
44
+ #
45
+ module Targeting
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ # Evaluates targeting rules against evaluation contexts.
6
+ #
7
+ # Rules are arrays of { "value" => X, "conditions" => {...} } hashes.
8
+ # First matching rule wins. Falls back to flag's default value if nothing matches.
9
+ #
10
+ # @example Rule structure
11
+ # [
12
+ # {
13
+ # "value" => "100",
14
+ # "conditions" => {
15
+ # "type" => "OR",
16
+ # "conditions" => [
17
+ # { "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" },
18
+ # { "attribute" => "role", "operator" => "IN", "value" => ["admin", "developer"] }
19
+ # ]
20
+ # }
21
+ # }
22
+ # ]
23
+ #
24
+ module TargetingEngine
25
+ OPERATORS = %w[
26
+ EQUALS NOT_EQUALS IN NOT_IN
27
+ CONTAINS NOT_CONTAINS STARTS_WITH ENDS_WITH
28
+ GREATER_THAN LESS_THAN GREATER_THAN_OR_EQUAL LESS_THAN_OR_EQUAL
29
+ MATCHES
30
+ ].freeze
31
+
32
+ class << self
33
+ # Evaluate targeting rules against a context
34
+ #
35
+ # @param rules [Array<Hash>, nil] Array of targeting rules with values
36
+ # @param context [Hash, nil] Evaluation context with attributes
37
+ # @return [String, nil] The matched rule's value, or nil if no match
38
+ def evaluate(rules, context)
39
+ return nil if rules.nil? || rules.empty?
40
+ return nil if context.nil? || context.empty?
41
+
42
+ # Normalize context keys to strings for comparison
43
+ normalized_context = normalize_context(context)
44
+
45
+ rules.each do |rule|
46
+ rule = rule.transform_keys(&:to_s)
47
+ conditions = rule["conditions"]
48
+ next unless conditions
49
+
50
+ if evaluate_rule(conditions, normalized_context)
51
+ return rule["value"]
52
+ end
53
+ end
54
+
55
+ nil # No rules matched
56
+ end
57
+
58
+ private
59
+
60
+ def normalize_context(context)
61
+ context.transform_keys(&:to_s).transform_values do |v|
62
+ v.is_a?(Symbol) ? v.to_s : v
63
+ end
64
+ end
65
+
66
+ # Evaluate an AND/OR rule block
67
+ def evaluate_rule(rule, context)
68
+ rule = rule.transform_keys(&:to_s)
69
+ type = rule["type"]&.upcase || "AND"
70
+ conditions = rule["conditions"] || []
71
+
72
+ case type
73
+ when "AND"
74
+ conditions.all? { |c| evaluate_condition(c, context) }
75
+ when "OR"
76
+ conditions.any? { |c| evaluate_condition(c, context) }
77
+ else
78
+ false
79
+ end
80
+ end
81
+
82
+ # Evaluate a single condition
83
+ def evaluate_condition(condition, context)
84
+ condition = condition.transform_keys(&:to_s)
85
+ attribute = condition["attribute"]
86
+ operator = condition["operator"]&.upcase
87
+ expected = condition["value"]
88
+
89
+ return false unless attribute && operator
90
+
91
+ actual = context[attribute]
92
+ return false if actual.nil?
93
+
94
+ case operator
95
+ when "EQUALS"
96
+ compare_equals(actual, expected)
97
+ when "NOT_EQUALS"
98
+ !compare_equals(actual, expected)
99
+ when "IN"
100
+ compare_in(actual, expected)
101
+ when "NOT_IN"
102
+ !compare_in(actual, expected)
103
+ when "CONTAINS"
104
+ compare_contains(actual, expected)
105
+ when "NOT_CONTAINS"
106
+ !compare_contains(actual, expected)
107
+ when "STARTS_WITH"
108
+ compare_starts_with(actual, expected)
109
+ when "ENDS_WITH"
110
+ compare_ends_with(actual, expected)
111
+ when "GREATER_THAN"
112
+ compare_numeric(actual, expected) { |a, e| a > e }
113
+ when "LESS_THAN"
114
+ compare_numeric(actual, expected) { |a, e| a < e }
115
+ when "GREATER_THAN_OR_EQUAL"
116
+ compare_numeric(actual, expected) { |a, e| a >= e }
117
+ when "LESS_THAN_OR_EQUAL"
118
+ compare_numeric(actual, expected) { |a, e| a <= e }
119
+ when "MATCHES"
120
+ compare_matches(actual, expected)
121
+ else
122
+ false
123
+ end
124
+ end
125
+
126
+ def compare_equals(actual, expected)
127
+ normalize_value(actual) == normalize_value(expected)
128
+ end
129
+
130
+ def compare_in(actual, expected)
131
+ return false unless expected.is_a?(Array)
132
+
133
+ normalized_actual = normalize_value(actual)
134
+ expected.any? { |e| normalize_value(e) == normalized_actual }
135
+ end
136
+
137
+ def compare_contains(actual, expected)
138
+ actual.to_s.include?(expected.to_s)
139
+ end
140
+
141
+ def compare_starts_with(actual, expected)
142
+ actual.to_s.start_with?(expected.to_s)
143
+ end
144
+
145
+ def compare_ends_with(actual, expected)
146
+ actual.to_s.end_with?(expected.to_s)
147
+ end
148
+
149
+ def compare_numeric(actual, expected)
150
+ a = to_number(actual)
151
+ e = to_number(expected)
152
+ return false if a.nil? || e.nil?
153
+
154
+ yield(a, e)
155
+ end
156
+
157
+ def compare_matches(actual, expected)
158
+ Regexp.new(expected.to_s).match?(actual.to_s)
159
+ rescue RegexpError
160
+ false
161
+ end
162
+
163
+ def normalize_value(value)
164
+ case value
165
+ when Symbol then value.to_s
166
+ when TrueClass then true
167
+ when FalseClass then false
168
+ when Numeric then value
169
+ else value.to_s
170
+ end
171
+ end
172
+
173
+ def to_number(value)
174
+ case value
175
+ when Numeric then value
176
+ when String
177
+ if value.include?(".")
178
+ Float(value)
179
+ else
180
+ Integer(value)
181
+ end
182
+ else
183
+ nil
184
+ end
185
+ rescue ArgumentError, TypeError
186
+ nil
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Subflag
4
4
  module Rails
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.1"
6
6
  end
7
7
  end
data/lib/subflag/rails.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "rails/client"
11
11
  require_relative "rails/flag_accessor"
12
12
  require_relative "rails/helpers"
13
13
  require_relative "rails/railtie" if defined?(Rails::Railtie)
14
+ require_relative "rails/engine" if defined?(Rails::Engine)
14
15
 
15
16
  # Test helpers are loaded separately: require "subflag/rails/test_helpers"
16
17
 
@@ -130,6 +131,7 @@ module Subflag
130
131
  end
131
132
 
132
133
  def build_active_record_provider
134
+ require_relative "rails/targeting"
133
135
  require_relative "rails/backends/active_record_provider"
134
136
  require_relative "rails/models/flag"
135
137
  Backends::ActiveRecordProvider.new
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: subflag-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.1
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-11 00:00:00.000000000 Z
11
+ date: 2025-12-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: openfeature-sdk
@@ -170,7 +170,7 @@ dependencies:
170
170
  - - ">="
171
171
  - !ruby/object:Gem::Version
172
172
  version: '6.1'
173
- description: Feature flags for Rails with pluggable backends. Use Subflag Cloud (SaaS),
173
+ description: Feature flags for Rails with selectable backends. Use Subflag Cloud (SaaS),
174
174
  ActiveRecord (self-hosted), or Memory (testing). Get typed values (boolean, string,
175
175
  integer, double, object) with the same API regardless of backend.
176
176
  email:
@@ -182,6 +182,14 @@ files:
182
182
  - CHANGELOG.md
183
183
  - LICENSE.txt
184
184
  - README.md
185
+ - app/controllers/subflag/rails/application_controller.rb
186
+ - app/controllers/subflag/rails/flags_controller.rb
187
+ - app/views/layouts/subflag/rails/application.html.erb
188
+ - app/views/subflag/rails/flags/_form.html.erb
189
+ - app/views/subflag/rails/flags/edit.html.erb
190
+ - app/views/subflag/rails/flags/index.html.erb
191
+ - app/views/subflag/rails/flags/new.html.erb
192
+ - config/routes.rb
185
193
  - lib/generators/subflag/install_generator.rb
186
194
  - lib/generators/subflag/templates/create_subflag_flags.rb.tt
187
195
  - lib/generators/subflag/templates/initializer.rb.tt
@@ -193,12 +201,15 @@ files:
193
201
  - lib/subflag/rails/client.rb
194
202
  - lib/subflag/rails/configuration.rb
195
203
  - lib/subflag/rails/context_builder.rb
204
+ - lib/subflag/rails/engine.rb
196
205
  - lib/subflag/rails/evaluation_result.rb
197
206
  - lib/subflag/rails/flag_accessor.rb
198
207
  - lib/subflag/rails/helpers.rb
199
208
  - lib/subflag/rails/models/flag.rb
200
209
  - lib/subflag/rails/railtie.rb
201
210
  - lib/subflag/rails/request_cache.rb
211
+ - lib/subflag/rails/targeting.rb
212
+ - lib/subflag/rails/targeting_engine.rb
202
213
  - lib/subflag/rails/test_helpers.rb
203
214
  - lib/subflag/rails/version.rb
204
215
  homepage: https://subflag.com