Skip to content

Commit 5f40bbc

Browse files
committed
Introduce a conditions normalizer
1 parent 0f568cf commit 5f40bbc

File tree

8 files changed

+264
-3
lines changed

8 files changed

+264
-3
lines changed

lib/cancan.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
if defined? ActiveRecord
1515
require 'cancan/model_adapters/conditions_extractor'
16+
require 'cancan/model_adapters/conditions_normalizer'
1617
require 'cancan/model_adapters/active_record_adapter'
1718
require 'cancan/model_adapters/active_record_4_adapter'
1819
require 'cancan/model_adapters/active_record_5_adapter'

lib/cancan/model_adapters/active_record_adapter.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def self.version_lower?(version)
1414
def initialize(model_class, rules)
1515
super
1616
@compressed_rules = RulesCompressor.new(@rules.reverse).rules_collapsed.reverse
17+
ConditionsNormalizer.normalize(model_class, @compressed_rules)
1718
end
1819

1920
# Returns conditions intended to be used inside a database query. Normally you will not call this

lib/cancan/model_adapters/conditions_extractor.rb

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@ def tableize_conditions(conditions, model_class = @root_model_class, path_to_key
2727

2828
def calculate_result_hash(key, model_class, path_to_key, result_hash, value)
2929
reflection = model_class.reflect_on_association(key)
30-
raise WrongAssociationName, "association #{key} not defined in model #{model_class.name}" unless reflection
31-
3230
nested_resulted = calculate_nested(model_class, result_hash, key, value.dup, path_to_key)
3331
association_class = reflection.klass.name.constantize
3432
tableize_conditions(nested_resulted, association_class, "#{path_to_key}_#{key}")
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# this class is responsible of normalizing the hash of conditions
2+
# by exploding has_many through associations
3+
# when a condition is defined with an has_many thorugh association this is exploded in all its parts
4+
# TODO: it could identify STI and normalize it
5+
module CanCan
6+
module ModelAdapters
7+
class ConditionsNormalizer
8+
class << self
9+
def normalize(model_class, rules)
10+
rules.each { |rule| rule.conditions = normalize_conditions(model_class, rule.conditions) }
11+
end
12+
13+
def normalize_conditions(model_class, conditions)
14+
return conditions unless conditions.is_a? Hash
15+
16+
conditions.each_with_object({}) do |(key, value), result_hash|
17+
if value.is_a? Hash
18+
result_hash.merge!(calculate_result_hash(model_class, key, value))
19+
else
20+
result_hash[key] = value
21+
end
22+
result_hash
23+
end
24+
end
25+
26+
private
27+
28+
def calculate_result_hash(model_class, key, value)
29+
reflection = model_class.reflect_on_association(key)
30+
unless reflection
31+
raise WrongAssociationName, "Association '#{key}' not defined in model '#{model_class.name}'"
32+
end
33+
34+
if reflection.options[:through].present?
35+
key = reflection.options[:through]
36+
value = { reflection.source_reflection_name => value }
37+
reflection = model_class.reflect_on_association(key)
38+
end
39+
40+
{ key => normalize_conditions(reflection.klass.name.constantize, value) }
41+
end
42+
end
43+
end
44+
end
45+
end

lib/cancan/rule.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class Rule # :nodoc:
77
include ConditionsMatcher
88
include ParameterValidators
99
attr_reader :base_behavior, :subjects, :actions, :conditions, :attributes
10-
attr_writer :expanded_actions
10+
attr_writer :expanded_actions, :conditions
1111

1212
# The first argument when initializing is the base_behavior which is a true/false
1313
# value. True for "can" and false for "cannot". The next two arguments are the action
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
require 'spec_helper'
2+
3+
# integration tests for latest ActiveRecord version.
4+
RSpec.describe CanCan::ModelAdapters::ActiveRecord5Adapter do
5+
let(:ability) { double.extend(CanCan::Ability) }
6+
let(:users_table) { Post.table_name }
7+
let(:posts_table) { Post.table_name }
8+
let(:likes_table) { Like.table_name }
9+
before :each do
10+
connect_db
11+
ActiveRecord::Migration.verbose = false
12+
13+
ActiveRecord::Schema.define do
14+
create_table(:users) do |t|
15+
t.string :name
16+
t.timestamps null: false
17+
end
18+
19+
create_table(:posts) do |t|
20+
t.string :title
21+
t.boolean :published, default: true
22+
t.integer :user_id
23+
t.timestamps null: false
24+
end
25+
26+
create_table(:likes) do |t|
27+
t.integer :post_id
28+
t.integer :user_id
29+
t.timestamps null: false
30+
end
31+
32+
create_table(:editors) do |t|
33+
t.integer :post_id
34+
t.integer :user_id
35+
t.timestamps null: false
36+
end
37+
end
38+
39+
class User < ActiveRecord::Base
40+
has_many :posts
41+
has_many :likes
42+
has_many :editors
43+
end
44+
45+
class Post < ActiveRecord::Base
46+
belongs_to :user
47+
has_many :likes
48+
has_many :editors
49+
end
50+
51+
class Like < ActiveRecord::Base
52+
belongs_to :user
53+
belongs_to :post
54+
end
55+
56+
class Editor < ActiveRecord::Base
57+
belongs_to :user
58+
belongs_to :post
59+
end
60+
end
61+
62+
before do
63+
@user1 = User.create!
64+
@user2 = User.create!
65+
@post1 = Post.create!(title: 'post1', user: @user1)
66+
@post2 = Post.create!(user: @user1, published: false)
67+
@post3 = Post.create!(user: @user2)
68+
@like1 = Like.create!(post: @post1, user: @user1)
69+
@like2 = Like.create!(post: @post1, user: @user2)
70+
@editor1 = Editor.create(user: @user1, post: @post2)
71+
ability.can :read, Post, user_id: @user1
72+
ability.can :read, Post, editors: { user_id: @user1 }
73+
end
74+
75+
describe 'preloading of associatons' do
76+
it 'preloads associations correctly' do
77+
posts = Post.accessible_by(ability).includes(likes: :user)
78+
expect(posts[0].association(:likes)).to be_loaded
79+
expect(posts[0].likes[0].association(:user)).to be_loaded
80+
end
81+
end
82+
83+
describe 'filtering of results' do
84+
it 'adds the where clause correctly' do
85+
posts = Post.accessible_by(ability).where(published: true)
86+
expect(posts.length).to eq 1
87+
end
88+
end
89+
90+
if CanCan::ModelAdapters::ActiveRecordAdapter.version_greater_or_equal?('5.0.0')
91+
describe 'selecting custom columns' do
92+
it 'extracts custom columns correctly' do
93+
posts = Post.accessible_by(ability).select('title as mytitle')
94+
expect(posts[0].mytitle).to eq 'post1'
95+
end
96+
end
97+
end
98+
end

spec/cancan/model_adapters/active_record_adapter_spec.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,25 @@
2525
t.timestamps null: false
2626
end
2727

28+
create_table(:companies) do |t|
29+
t.boolean :admin
30+
end
31+
2832
create_table(:articles) do |t|
2933
t.string :name
3034
t.timestamps null: false
3135
t.boolean :published
3236
t.boolean :secret
3337
t.integer :priority
3438
t.integer :category_id
39+
t.integer :project_id
3540
t.integer :user_id
3641
end
3742

3843
create_table(:comments) do |t|
3944
t.boolean :spam
4045
t.integer :article_id
46+
t.integer :project_id
4147
t.timestamps null: false
4248
end
4349

@@ -54,18 +60,24 @@
5460
end
5561

5662
class Project < ActiveRecord::Base
63+
has_many :comments
5764
end
5865

5966
class Category < ActiveRecord::Base
6067
has_many :articles
6168
end
6269

70+
class Company < ActiveRecord::Base
71+
end
72+
6373
class Article < ActiveRecord::Base
6474
belongs_to :category
75+
belongs_to :company
6576
has_many :comments
6677
has_many :mentions
6778
has_many :mentioned_users, through: :mentions, source: :user
6879
belongs_to :user
80+
belongs_to :project
6981
end
7082

7183
class Mention < ActiveRecord::Base
@@ -502,4 +514,27 @@ class Transaction < ActiveRecord::Base
502514
expect(Article.accessible_by(ability)).to match_array([a1])
503515
end
504516
end
517+
518+
context 'has_many through is defined and referenced differently' do
519+
it 'recognises it and simplifies the query' do
520+
u1 = User.create!(name: 'pippo')
521+
u2 = User.create!(name: 'paperino')
522+
523+
a1 = Article.create!(mentioned_users: [u1])
524+
a2 = Article.create!(mentioned_users: [u2])
525+
526+
ability = Ability.new(u1)
527+
ability.can :read, Article, mentioned_users: { name: u1.name }
528+
ability.can :read, Article, mentions: { user: { name: u2.name } }
529+
expect(Article.accessible_by(ability)).to match_array([a1, a2])
530+
if CanCan::ModelAdapters::ActiveRecordAdapter.version_greater_or_equal?('5.0.0')
531+
expect(ability.model_adapter(Article, :read)).to generate_sql(%(
532+
SELECT DISTINCT "articles".*
533+
FROM "articles"
534+
LEFT OUTER JOIN "legacy_mentions" ON "legacy_mentions"."article_id" = "articles"."id"
535+
LEFT OUTER JOIN "users" ON "users"."id" = "legacy_mentions"."user_id"
536+
WHERE (("users"."name" = 'paperino') OR ("users"."name" = 'pippo'))))
537+
end
538+
end
539+
end
505540
end
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
require 'spec_helper'
2+
3+
RSpec.describe CanCan::ModelAdapters::ConditionsNormalizer do
4+
before do
5+
connect_db
6+
ActiveRecord::Migration.verbose = false
7+
ActiveRecord::Schema.define do
8+
create_table(:articles) do |t|
9+
end
10+
11+
create_table(:users) do |t|
12+
t.string :name
13+
end
14+
15+
create_table(:comments) do |t|
16+
end
17+
18+
create_table(:spread_comments) do |t|
19+
t.integer :article_id
20+
t.integer :comment_id
21+
end
22+
23+
create_table(:legacy_mentions) do |t|
24+
t.integer :user_id
25+
t.integer :article_id
26+
end
27+
end
28+
29+
class Article < ActiveRecord::Base
30+
has_many :spread_comments
31+
has_many :comments, through: :spread_comments
32+
has_many :mentions
33+
has_many :mentioned_users, through: :mentions, source: :user
34+
end
35+
36+
class Comment < ActiveRecord::Base
37+
has_many :spread_comments
38+
has_many :articles, through: :spread_comments
39+
end
40+
41+
class SpreadComment < ActiveRecord::Base
42+
belongs_to :comment
43+
belongs_to :article
44+
end
45+
46+
class Mention < ActiveRecord::Base
47+
self.table_name = 'legacy_mentions'
48+
belongs_to :article
49+
belongs_to :user
50+
end
51+
52+
class User < ActiveRecord::Base
53+
has_many :mentions
54+
has_many :mentioned_articles, through: :mentions, source: :article
55+
end
56+
end
57+
58+
it 'simplifies has_many through associations' do
59+
rule = CanCan::Rule.new(true, :read, Comment, articles: { mentioned_users: { name: 'pippo' } })
60+
CanCan::ModelAdapters::ConditionsNormalizer.normalize(Comment, [rule])
61+
expect(rule.conditions).to eq(spread_comments: { article: { mentions: { user: { name: 'pippo' } } } })
62+
end
63+
64+
it 'normalizes the has_one through associations' do
65+
class Supplier < ActiveRecord::Base
66+
has_one :accountant
67+
has_one :account_history, through: :accountant
68+
end
69+
70+
class Accountant < ActiveRecord::Base
71+
belongs_to :supplier
72+
has_one :account_history
73+
end
74+
75+
class AccountHistory < ActiveRecord::Base
76+
belongs_to :accountant
77+
end
78+
79+
rule = CanCan::Rule.new(true, :read, Supplier, account_history: { name: 'pippo' })
80+
CanCan::ModelAdapters::ConditionsNormalizer.normalize(Supplier, [rule])
81+
expect(rule.conditions).to eq(accountant: { account_history: { name: 'pippo' } })
82+
end
83+
end

0 commit comments

Comments
 (0)