class DeclarativePolicy::Runner
Attributes
a Runner
contains a list of Steps to be run.
Public Class Methods
# File lib/declarative_policy/runner.rb, line 35 def initialize(steps) @steps = steps @state = nil end
Public Instance Methods
We make sure only to run any given Runner
once, and just continue to use the resulting @state that's left behind.
# File lib/declarative_policy/runner.rb, line 43 def cached? !!@state end
see DeclarativePolicy::Base#debug
# File lib/declarative_policy/runner.rb, line 67 def debug(out = $stderr) run(out) end
# File lib/declarative_policy/runner.rb, line 54 def merge_runner(other) Runner.new(@steps + other.steps) end
The main entry point, called for making an ability decision. See run
and DeclarativePolicy::Base#can?
# File lib/declarative_policy/runner.rb, line 60 def pass? run unless cached? @state.pass? end
used by Rule::Ability
. See steps_by_score
# File lib/declarative_policy/runner.rb, line 48 def score return 0 if cached? steps.sum(&:score) end
Private Instance Methods
# File lib/declarative_policy/runner.rb, line 73 def flatten_steps! @steps = @steps.flat_map { |s| s.flattened(@steps) } end
Formatter for debugging output.
# File lib/declarative_policy/runner.rb, line 192 def inspect_step(step, original_score, passed) symbol = case passed when true then '+' when false then '-' when nil then ' ' end "#{symbol} [#{original_score.to_i}] #{step.repr}\n" end
# File lib/declarative_policy/runner.rb, line 173 def next_step_and_score(remaining_steps) lowest_score = Float::INFINITY next_step = nil remaining_steps.each do |step| score = step.score if score < lowest_score next_step = step lowest_score = score end break if lowest_score.zero? end [next_step, score] end
This method implements the semantic of “one enable and no prevents”. It relies on steps_by_score
for the main loop, and updates @state with the result of the step.
# File lib/declarative_policy/runner.rb, line 80 def run(debug = nil) @state = State.new steps_by_score do |step, score| break if !debug && @state.prevented? passed = nil case step.action when :enable # we only check :enable actions if they have a chance of # changing the outcome - if no other rule has enabled or # prevented. unless @state.enabled? || @state.prevented? passed = step.pass? @state.enable! if passed end debug << inspect_step(step, score, passed) if debug when :prevent # we only check :prevent actions if the state hasn't already # been prevented. unless @state.prevented? passed = step.pass? @state.prevent! if passed end debug << inspect_step(step, score, passed) if debug else raise "invalid action #{step.action.inspect}" end end @state end
This is the core spot where all those `#score` methods matter. It is critical for performance to run steps in the correct order, so that we don't compute expensive conditions (potentially n times if we're called on, say, a large list of users).
In order to determine the cheapest step to run next, we rely on Step#score
, which returns a numerical rating of how expensive it would be to calculate - the lower the better. It would be easy enough to statically sort by these scores, but we can do a little better - the scores are cache-aware (conditions that are already in the cache have score 0), which means that running a step can actually change the scores of other steps.
So! The way we sort here involves re-scoring at every step. This is by necessity quadratic, but most of the time the number of steps will be low. But just in case, if the number of steps exceeds 50, we print a warning and fall back to a static sort.
For each step, we yield the step object along with the computed score for debugging purposes.
# File lib/declarative_policy/runner.rb, line 134 def steps_by_score flatten_steps! if @steps.size > 50 warn "DeclarativePolicy: large number of steps (#{steps.size}), falling back to static sort" @steps.map { |s| [s.score, s] }.sort_by { |(score, _)| score }.each do |(score, step)| yield step, score end return end remaining_steps = Set.new(@steps) remaining_enablers, remaining_preventers = remaining_steps.partition(&:enable?).map { |s| Set.new(s) } loop do if @state.enabled? # Once we set this, we never need to unset it, because a single # prevent will stop this from being enabled remaining_steps = remaining_preventers elsif remaining_enablers.empty? # if the permission hasn't yet been enabled and we only have # prevent steps left, we short-circuit the state here @state.prevent! end return if remaining_steps.empty? next_step, lowest_score = next_step_and_score(remaining_steps) [remaining_steps, remaining_enablers, remaining_preventers].each do |set| set.delete(next_step) end yield next_step, lowest_score end end