class DeclarativePolicy::Runner

Attributes

steps[R]

a Runner contains a list of Steps to be run.

Public Class Methods

new(steps) click to toggle source
# File lib/declarative_policy/runner.rb, line 35
def initialize(steps)
  @steps = steps
  @state = nil
end

Public Instance Methods

cached?() click to toggle source

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
debug(out = $stderr) click to toggle source

see DeclarativePolicy::Base#debug

# File lib/declarative_policy/runner.rb, line 67
def debug(out = $stderr)
  run(out)
end
merge_runner(other) click to toggle source
# File lib/declarative_policy/runner.rb, line 54
def merge_runner(other)
  Runner.new(@steps + other.steps)
end
pass?() click to toggle source

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
score() click to toggle source

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

flatten_steps!() click to toggle source
# File lib/declarative_policy/runner.rb, line 73
def flatten_steps!
  @steps = @steps.flat_map { |s| s.flattened(@steps) }
end
inspect_step(step, original_score, passed) click to toggle source

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
next_step_and_score(remaining_steps) click to toggle source
# 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
run(debug = nil) click to toggle source

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
steps_by_score() { |step, score| ... } click to toggle source

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