Skip to content

Commit

Permalink
Implement initial support for the Squeel adapter.
Browse files Browse the repository at this point in the history
  • Loading branch information
lowjoel committed Mar 29, 2016
1 parent 62153d5 commit c820a15
Show file tree
Hide file tree
Showing 7 changed files with 383 additions and 6 deletions.
8 changes: 7 additions & 1 deletion cancancan-squeel.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@ Gem::Specification.new do |spec|
spec.require_paths = ['lib']

spec.add_development_dependency 'bundler', '~> 1.11'
spec.add_development_dependency 'rake', '~> 10.0'
spec.add_development_dependency 'rake'
spec.add_development_dependency 'rspec', '~> 3.0'
spec.add_development_dependency 'simplecov'
spec.add_development_dependency 'coveralls'
spec.add_development_dependency 'codeclimate-test-reporter'

spec.add_development_dependency 'sqlite3'

spec.add_dependency 'activerecord', '>= 4.1'
spec.add_dependency 'cancancan', '~> 1.10'
spec.add_dependency 'squeel', '~> 1.2'
end
9 changes: 6 additions & 3 deletions lib/cancancan/squeel.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
require 'active_record'
require 'cancancan'
require 'squeel'

require 'cancancan/squeel/version'
require 'cancancan/squeel/active_record_disabler'
require 'cancancan/squeel/squeel_adapter'

module CanCanCan::Squeel
# Your code goes here...
end
7 changes: 7 additions & 0 deletions lib/cancancan/squeel/active_record_disabler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class CanCanCan::Squeel::ActiveRecordDisabler
::CanCan::ModelAdapters::ActiveRecord4Adapter.class_eval do
def self.for_class?(_)
false
end
end
end
195 changes: 195 additions & 0 deletions lib/cancancan/squeel/squeel_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
class CanCanCan::Squeel::SqueelAdapter < CanCan::ModelAdapters::AbstractAdapter
def self.for_class?(model_class)
model_class <= ActiveRecord::Base
end

def self.override_condition_matching?(subject, name, _)
return false unless subject.class.respond_to?(:defined_enums)

subject.class.defined_enums.include?(name.to_s)
end

def self.matches_condition?(subject, name, value)
# Get the mapping from enum strings to values.
enum = subject.class.public_send(name.to_s.pluralize)

# Get the value of the attribute as an integer.
attribute = enum[subject.public_send(name)]

# Check to see if the value matches the condition.
value.is_a?(Enumerable) ? value.include?(attribute) : attribute == value
end

def database_records
# TODO: Handle overridden scopes.
relation.distinct
end

private

# Builds a relation that expresses the set of provided rules.
#
# This first joins all the tables specified in the rules, then builds the corresponding Squeel
# expression for the conditions.
def relation
join_scope = @rules.reduce(@model_class.where(nil)) do |scope, rule|
add_joins_to_scope(scope, build_join_list(rule.conditions))
end

add_conditions_to_scope(join_scope)
end

# Builds an array of joins for the given conditions hash.
#
# For example:
#
# a: { b: { c: 3 }, d: { e: 4 }} => [[:a, :b], [:a, :d]]
#
# @param [Hash] conditions The conditions to build the joins.
# @return [Array<Array<Symbol>>] The joins needed to satisfy the given conditions
def build_join_list(conditions)
conditions.flat_map do |key, value|
if value.is_a?(Hash)
[[key]].concat(build_join_list(value).map { |join| Array(join).unshift(key) })
else
[]
end
end
end

# Builds a relation, outer joined on the provided associations.
#
# @param [ActiveRecord::Relation] scope The current scope to add the joins to.
# @param [Array<Array<Symbol>>] joins The set of associations to outer join with.
# @return [ActiveRecord::Relation] The built relation.
def add_joins_to_scope(scope, joins)
joins.reduce(scope) do |result, join|
result.joins do
join.reduce(self) do |relation, association|
relation.__send__(association).outer
end
end
end
end

# Adds the rule conditions to the scope.
#
# This builds Squeel expression for each rule, and combines the expression with those to the left
# using a fold-left.
#
# @param [ActiveRecord::Relation] scope The scope to add the rule conditions to.
def add_conditions_to_scope(scope)
adapter = self
rules = @rules

# default n
scope.where do
rules.reduce(nil) do |left_expression, rule|
combined_rule = adapter.send(:combine_expression_with_rule, self, left_expression, rule)
break if combined_rule.nil?

combined_rule
end
end
end

# Combines the given expression with the new rule.
#
# @param squeel The Squeel scope.
# @param left_expression The Squeel expression for all preceding rules.
# @param [CanCan::Rule] rule The rule being added.
# @return [Squeel::Nodes::Node] If the rule has an expression.
# @return [NilClass] If the rule is unconditional.
def combine_expression_with_rule(squeel, left_expression, rule)
right_expression = build_expression_from_rule(squeel, rule)
return right_expression if right_expression.nil? || !left_expression

if rule.base_behavior
left_expression | right_expression
else
left_expression & right_expression
end
end

# Builds a Squeel expression representing the rule's conditions.
#
# @param squeel The Squeel scope.
# @param [CanCan::Rule] rule The rule being built.
def build_expression_from_rule(squeel, rule)
comparator = rule.base_behavior ? :== : :!=
build_expression_node(squeel, @model_class, comparator, rule.conditions, true)
end

# Builds a new Squeel expression node.
#
# @param node The parent node context.
# @param [Class] model_class The model class which the conditions reference.
# @param [Symbol] comparator The comparator to use when generating the comparison.
# @param [Hash] conditions The values to compare the given node's attributes against.
# @param [Boolean] root True if the node being built is from the root. The root node is special
# because it does not mutate itself; all other nodes do.
def build_expression_node(node, model_class, comparator, conditions, root = false)
conditions.reduce(nil) do |left_expression, (key, value)|
comparison_node = build_comparison_node(root ? node : node.dup, model_class, key,
comparator, value)
if left_expression
left_expression & comparison_node
else
comparison_node
end
end
end

# Builds a comparison node for the given key and value.
#
# @param node The node context to build the comparison.
# @param [Class] model_class The model class which the conditions reference.
# @param [Symbol] key The column to compare against.
# @param [Symbol] comparator The comparator to compare the column against the value.
# @param value The value to compare the column against.
def build_comparison_node(node, model_class, key, comparator, value)
if value.is_a?(Hash)
reflection = model_class.reflect_on_association(key)
build_expression_node(node.__send__(key), reflection.klass, comparator, value)
else
key, comparator, value = squeel_comparison_for(model_class, key, comparator, value)
node.__send__(key).public_send(comparator, value)
end
end

# Picks the appropriate column, comparator, and value to use in the Squeel expression.
#
# This checks for association references: this will use the appropriate column name.
#
# Array values are interpreted as alternative choices allowed or disallowed.
#
# @param [Class] model_class The model class which the key references.
# @param [Symbol] key The column being compared.
# @param [Symbol] comparator The comparator to get the appropriate Squeel comparator for.
# @param value The value to be comparing against.
# @return [Array<(Symbol, Symbol, Object)>] A triple containing the column to compare with, the
# comparator to use, and the value to compare with.
def squeel_comparison_for(model_class, key, comparator, value)
if (association = model_class.reflect_on_association(key))
key = association.foreign_key
end

comparator = squeel_comparator_for(comparator, value)
[key, comparator, value]
end

# Maps the given comparator to a comparator appropriate for the given value.
#
# Array values are interpreted as alternative choices allowed or disallowed.
#
# @param [Symbol] comparator The comparator to get the appropriate Squeel comparator for.
# @param value The value to be comparing against.
# @return [Symbol] The comparator for the desired effect, suitable for the given type.
def squeel_comparator_for(comparator, value)
case [comparator, value]
when :==, Array then :>>
when :!=, Array then :<<
else comparator
end
end
end
69 changes: 67 additions & 2 deletions spec/cancancan/squeel_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,72 @@
expect(CanCanCan::Squeel::VERSION).not_to be nil
end

it 'does something useful' do
expect(false).to eq(true)
let(:ability) { double.extend(CanCan::Ability) }

with_database(:sqlite) do
it 'respects scope on included associations' do
ability.can :read, [Parent, Child]

parent = Parent.create!
child1 = Child.create!(parent: parent, created_at: 1.hours.ago)
child2 = Child.create!(parent: parent, created_at: 2.hours.ago)

parents = Parent.accessible_by(ability).order(created_at: :asc).includes(:children)
expect(parents.first.children).to eq([child2, child1])
end

it 'supports repeated tables in deeply nested conditions' do
parent1 = Parent.create!
parent2 = Parent.create!
ability.can :read, Parent, children: {
other_parent: {
id: parent1.id
}
}

# check that we are not directly accessible
expect(Parent.accessible_by(ability)).to be_empty

_child = Child.create!(parent: parent2, other_parent: parent1)
expect(Parent.accessible_by(ability)).to contain_exactly(parent2)
end

it 'allows using accessible_by on a chained scope' do
parent1 = Parent.create!
parent2 = Parent.create!
parent3 = Parent.create!
_child1 = Child.create!(parent: parent1, other_parent: parent2)
_child2 = Child.create!(parent: parent2, other_parent: parent3)
ability.can :read, Parent, other_parents: { id: parent3.id }

expect(parent1.other_parents.accessible_by(ability)).to contain_exactly(parent2)
end

it 'allows filters on enums' do
red = Shape.create!(color: :red)
green = Shape.create!(color: :green)
blue = Shape.create!(color: :blue)

# A condition with a single value.
ability.can(:read, Shape, color: Shape.colors[:green])

expect(ability.cannot?(:read, red)).to be true
expect(ability.can?(:read, green)).to be true
expect(ability.cannot?(:read, blue)).to be true

accessible = Shape.accessible_by(ability)
expect(accessible).to contain_exactly(green)

# A condition with multiple values.
ability.can(:update, Shape, color: [Shape.colors[:red],
Shape.colors[:blue]])

expect(ability.can?(:update, red)).to be true
expect(ability.cannot?(:update, green)).to be true
expect(ability.can?(:update, blue)).to be true

accessible = Shape.accessible_by(ability, :update)
expect(accessible).to contain_exactly(red, blue)
end
end
end
1 change: 1 addition & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
require 'coverage_helper'
require 'cancancan/squeel'
Dir["#{__dir__}/support/*.rb"].each { |f| require f }

RSpec.configure do |config|
# rspec-expectations config goes here. You can use an alternate
Expand Down
Loading

0 comments on commit c820a15

Please sign in to comment.