Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions lib/flipper/expression.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,80 @@ def eql?(other)
end
alias_method :==, :eql?

# Public: Returns true if this expression contains an Any/All group with
# no arguments anywhere in its tree. An empty All evaluates to true for
# every actor and an empty Any to false for every actor, so persisting
# one is almost always a mistake.
def empty_groups?
return true if group? && args.empty?

args.any? { |arg| arg.is_a?(Expression) && arg.empty_groups? }
end

# Public: Returns a copy of this expression with empty Any/All groups
# removed wherever removal cannot broaden the expression, or nil when no
# conditions remain. An empty All can be removed from an All parent (it
# contributes a constant true) and an empty Any from an Any parent (a
# constant false), but an empty Any inside an All is kept because
# removing it would broaden the expression from never-true.
def prune_empty_groups
prune_empty_groups_with_identity.first
end

# Public: Returns true when this expression is guaranteed to match every
# actor, e.g. an empty All group reaches an Any group or the root.
def always_true?
constant_group_value == true
end

def value
{
name => args.map(&:value)
}
end

protected

def prune_empty_groups_with_identity
return [self, nil] unless group?
return [nil, all?] if args.empty?

pruned_args = args.map do |arg|
arg.is_a?(Expression) ? arg.prune_empty_groups_with_identity : [arg, nil]
end
kept = pruned_args.map(&:first).compact

# No condition remains. Preserve this group's constant value for an
# enclosing group, which lets an empty All disappear from All without
# treating it as the unsafe empty-Any-in-All case.
return [nil, constant_group_value] if kept.empty?

if all? && pruned_args.any? { |value, identity| value.nil? && identity == false }
return [self, nil]
end

[build(name => kept), nil]
end

def constant_group_value
return nil unless group?
return all? if args.empty?

values = args.map do |arg|
arg.is_a?(Expression) ? arg.constant_group_value : nil
end

if all?
return false if values.include?(false)
return true if values.all?(true)
else
return true if values.include?(true)
return false if values.all?(false)
end

nil
end

private

def call_with_context?
Expand Down
42 changes: 35 additions & 7 deletions lib/flipper/feature.rb
Original file line number Diff line number Diff line change
Expand Up @@ -124,21 +124,29 @@ def enabled?(*actors)
# expression - an Expression or Hash that can be converted to an expression.
#
# Returns result of enable.
# Raises ArgumentError if the expression contains empty Any/All groups,
# because an empty All matches every actor.
def enable_expression(expression)
enable Expression.build(expression)
enable validate_expression!(Expression.build(expression))
end

# Public: Add an expression for a feature.
#
# expression_to_add - an expression or Hash that can be converted to an expression.
#
# Returns result of enable.
# Raises ArgumentError if the resulting expression contains empty Any/All
# groups, because an empty All matches every actor.
def add_expression(expression_to_add)
if (current_expression = expression)
enable current_expression.add(expression_to_add)
expression_to_add = Expression.build(expression_to_add)

new_expression = if (current_expression = expression)
current_expression.add(expression_to_add)
else
enable expression_to_add
expression_to_add
end

enable validate_expression!(new_expression)
end

# Public: Enables a feature for an actor.
Expand Down Expand Up @@ -191,14 +199,21 @@ def disable_expression
end

# Public: Remove an expression from a feature. Does nothing if no expression is
# currently enabled.
# currently enabled. Removing the last condition disables the expression
# rather than persisting an empty group.
#
# expression - an Expression or Hash that can be converted to an expression.
#
# Returns result of enable or nil (if no expression enabled).
# Returns result of enable or disable or nil (if no expression enabled).
def remove_expression(expression_to_remove)
if (current_expression = expression)
enable current_expression.remove(expression_to_remove)
remaining = current_expression.remove(expression_to_remove)

if remaining.group? && remaining.args.empty?
disable_expression
else
enable remaining
end
end
end

Expand Down Expand Up @@ -427,6 +442,19 @@ def gate_for(actor)

private

# Private: Raises if an expression about to be enabled contains empty
# Any/All groups. An empty All evaluates to true for every actor, so
# persisting one silently enables the feature for everyone.
#
# Returns the expression.
def validate_expression!(expression)
if expression.is_a?(Expression) && expression.empty_groups?
raise ArgumentError, "#{expression.value.inspect} contains empty Any/All groups and cannot be enabled (an empty All matches every actor). Remove the empty groups or add conditions to them."
end

expression
end

# Private: Wrap the actors passed to enabled? in a single pass to avoid
# intermediate array allocations on the hot path. Arrays are flattened one
# level; the explicit is_a?(Array) check (instead of flatten) avoids the
Expand Down
2 changes: 1 addition & 1 deletion spec/flipper/dsl_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@
expect(subject[:stats].expression).to eq(any_expression)

subject.remove_expression(:stats, expression)
expect(subject[:stats].expression).to eq(Flipper.any)
expect(subject[:stats].expression).to be(nil)
end
end

Expand Down
85 changes: 85 additions & 0 deletions spec/flipper/expression_pruning_property_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
require 'flipper/expression'

# Property-based check that empty group detection and pruning honor their
# contracts, verified against the evaluator itself. For seeded random trees
# (biased toward empty Any/All groups, including multi-key hashes where only
# the first key counts):
#
# - expressions without empty groups prune to themselves
# - pruning never broadens: no context excluded by the original may be
# included by the pruned expression
# - pruning to nil only happens when the original is a constant
# - always_true? implies the expression evaluates true in every context
#
# The seed is fixed for determinism; override with FUZZ_SEED/FUZZ_TREES for
# exploratory runs.
RSpec.describe Flipper::Expression do
SEED = (ENV["FUZZ_SEED"] || 20260728).to_i
TREES = (ENV["FUZZ_TREES"] || 1500).to_i

PLANS = %w[basic pro enterprise].freeze

CONTEXTS = PLANS.flat_map do |plan|
(0..50).step(10).map { |age| {properties: {"plan" => plan, "age" => age}} }
end.freeze

def random_leaf(rng)
case rng.rand(4)
when 0 then {"Equal" => [{"Property" => ["plan"]}, PLANS[rng.rand(3)]]}
when 1 then {"NotEqual" => [{"Property" => ["plan"]}, PLANS[rng.rand(3)]]}
when 2 then {"GreaterThan" => [{"Property" => ["age"]}, rng.rand(50)]}
else {"LessThan" => [{"Property" => ["age"]}, rng.rand(50)]}
end
end

def random_tree(rng, depth)
return random_leaf(rng) if depth <= 0 || rng.rand < 0.25

operator = rng.rand < 0.5 ? "Any" : "All"
children = Array.new(rng.rand(4)) { random_tree(rng, depth - 1) }
tree = {operator => children}

# Multi-key hashes must behave as if only the first key exists, matching
# Flipper::Expression.build.
if rng.rand < 0.05
other = operator == "Any" ? "All" : "Any"
tree[other] = Array.new(rng.rand(3)) { random_tree(rng, depth - 1) }
end

tree
end

def evaluate_all(expression)
CONTEXTS.map { |context| !!expression.evaluate(context) }
end

it "prunes without ever broadening the expression" do
rng = Random.new(SEED)

TREES.times do
tree = random_tree(rng, 4)
expression = described_class.build(tree)
failure = "seed=#{SEED} tree=#{tree.inspect}"

values = evaluate_all(expression)
pruned = expression.prune_empty_groups

if expression.always_true?
expect(values).to all(be(true)), "#{failure} always_true? but not always true"
end

unless expression.empty_groups?
expect(pruned).to eq(expression), "#{failure} pruning changed a clean expression"
next
end

if pruned.nil?
expect(values.uniq.size).to eq(1), "#{failure} pruned to nil but original is not constant"
else
evaluate_all(pruned).zip(values).each do |after, before|
expect(after && !before).to be(false), "#{failure} pruning broadened the expression"
end
end
end
end
end
78 changes: 78 additions & 0 deletions spec/flipper/expression_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,82 @@
expect(expression == other).to be(false)
end
end

describe "#empty_groups?" do
it "returns true for an empty group" do
expect(described_class.build({"Any" => []}).empty_groups?).to be(true)
expect(described_class.build({"All" => []}).empty_groups?).to be(true)
end

it "returns true for a nested empty group" do
expression = described_class.build({
"Any" => [{"Equal" => [{"Property" => ["plan"]}, "basic"]}, {"All" => []}],
})
expect(expression.empty_groups?).to be(true)
end

it "returns false for groups with conditions" do
expression = described_class.build({
"Any" => [{"All" => [{"Equal" => [{"Property" => ["plan"]}, "basic"]}]}],
})
expect(expression.empty_groups?).to be(false)
end

it "returns false for a condition" do
expression = described_class.build({"Equal" => [{"Property" => ["plan"]}, "basic"]})
expect(expression.empty_groups?).to be(false)
end
end

describe "#prune_empty_groups" do
let(:condition) { {"Equal" => [{"Property" => ["plan"]}, "basic"]} }

it "returns nil when no conditions remain" do
expect(described_class.build({"Any" => []}).prune_empty_groups).to be(nil)
expect(described_class.build({"All" => []}).prune_empty_groups).to be(nil)
expect(described_class.build({"Any" => [{"All" => []}]}).prune_empty_groups).to be(nil)
end

it "removes an empty All from an All parent" do
expression = described_class.build({"All" => [condition, {"All" => []}]})
expect(expression.prune_empty_groups).to eq(described_class.build({"All" => [condition]}))
end

it "removes an empty Any from an Any parent" do
expression = described_class.build({"Any" => [condition, {"Any" => []}]})
expect(expression.prune_empty_groups).to eq(described_class.build({"Any" => [condition]}))
end

it "keeps an empty Any inside an All because removal would broaden" do
expression = described_class.build({"All" => [condition, {"Any" => []}]})
expect(expression.prune_empty_groups).to eq(expression)
end

it "returns a condition untouched" do
expression = described_class.build(condition)
expect(expression.prune_empty_groups).to eq(expression)
end
end

describe "#always_true?" do
it "returns true for an empty All" do
expect(described_class.build({"All" => []}).always_true?).to be(true)
end

it "returns true for an empty All inside an Any" do
expression = described_class.build({
"Any" => [{"Equal" => [{"Property" => ["plan"]}, "basic"]}, {"All" => []}],
})
expect(expression.always_true?).to be(true)
end

it "returns false for an empty Any" do
expect(described_class.build({"Any" => []}).always_true?).to be(false)
end

it "returns false for conditional expressions" do
expression = described_class.build({"Equal" => [{"Property" => ["plan"]}, "basic"]})
expect(expression.always_true?).to be(false)
end
end
end
Loading
Loading