Skip to content

Commit

Permalink
Support using a nil relation as a condition (#653)
Browse files Browse the repository at this point in the history
  • Loading branch information
ghiculescu committed Jul 6, 2022
1 parent 603d5e9 commit 8efb522
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## Unreleased

* [#653](https://github.com/CanCanCommunity/cancancan/pull/653): Add support for using an nil relation as a condition. ([@ghiculescu][])
* [#702](https://github.com/CanCanCommunity/cancancan/pull/702): Support scopes of STI classes as ability conditions. ([@honigc][])

## 3.4.0
Expand Down
7 changes: 7 additions & 0 deletions docs/hash_of_conditions.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ An array or range can be passed to match multiple values. Here the user can only
can :read, Project, priority: 1..3
```

If you want to a negative match, you can pass in `nil`.

```ruby
# Can read projects that don't have any members.
can :read, Project, members: { id: nil }
```

Almost anything that you can pass to a hash of conditions in ActiveRecord will work here as well.

## Traverse associations
Expand Down
19 changes: 18 additions & 1 deletion lib/cancan/conditions_matcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,29 @@ def condition_match?(attribute, value)

def hash_condition_match?(attribute, value)
if attribute.is_a?(Array) || (defined?(ActiveRecord) && attribute.is_a?(ActiveRecord::Relation))
attribute.to_a.any? { |element| matches_conditions_hash?(element, value) }
array_like_matches_condition_hash?(attribute, value)
else
attribute && matches_conditions_hash?(attribute, value)
end
end

def array_like_matches_condition_hash?(attribute, value)
if attribute.any?
attribute.any? { |element| matches_conditions_hash?(element, value) }
else
# you can use `nil`s in your ability definition to tell cancancan to find
# objects that *don't* have any children in a has_many relationship.
#
# for example, given ability:
# => can :read, Article, comments: { id: nil }
# cancancan will return articles where `article.comments == []`
#
# this is implemented here. `attribute` is `article.comments`, and it's an empty array.
# the expression below returns true if this was expected.
!value.values.empty? && value.values.all?(&:nil?)
end
end

def call_block_with_all(action, subject, *extra_args)
if subject.class == Class
@block.call(action, subject, nil, *extra_args)
Expand Down
193 changes: 193 additions & 0 deletions spec/cancan/model_adapters/active_record_adapter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,199 @@ class User < ActiveRecord::Base
end
end

it 'allows an empty array to be used as a condition for a has_many, but this is never a passing condition' do
a1 = Article.create!
a2 = Article.create!
a2.comments = [Comment.create!]

@ability.can :read, Article, comment_ids: []

expect(@ability.can?(:read, a1)).to eq(false)
expect(@ability.can?(:read, a2)).to eq(false)

expect(Article.accessible_by(@ability)).to eq([])

if CanCan::ModelAdapters::ActiveRecordAdapter.version_greater_or_equal?('5.0.0')
expect(@ability.model_adapter(Article, :read)).to generate_sql(%(
SELECT "articles".*
FROM "articles"
WHERE 1=0))
end
end

it 'allows a nil to be used as a condition for a has_many - with join' do
a1 = Article.create!
a2 = Article.create!
a2.comments = [Comment.create!]

@ability.can :read, Article, comments: { id: nil }

expect(@ability.can?(:read, a1)).to eq(true)
expect(@ability.can?(:read, a2)).to eq(false)

expect(Article.accessible_by(@ability)).to eq([a1])

if CanCan::ModelAdapters::ActiveRecordAdapter.version_greater_or_equal?('5.0.0')
expect(@ability.model_adapter(Article, :read)).to generate_sql(%(
SELECT "articles".*
FROM "articles"
WHERE "articles"."id" IN (SELECT "articles"."id" FROM "articles"
LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id"
WHERE "comments"."id" IS NULL)))
end
end

it 'allows several nils to be used as a condition for a has_many - with join' do
a1 = Article.create!
a2 = Article.create!
a2.comments = [Comment.create!]

@ability.can :read, Article, comments: { id: nil, spam: nil }

expect(@ability.can?(:read, a1)).to eq(true)
expect(@ability.can?(:read, a2)).to eq(false)

expect(Article.accessible_by(@ability)).to eq([a1])

if CanCan::ModelAdapters::ActiveRecordAdapter.version_greater_or_equal?('5.0.0')
expect(@ability.model_adapter(Article, :read)).to generate_sql(%(
SELECT "articles".*
FROM "articles"
WHERE "articles"."id" IN (SELECT "articles"."id" FROM "articles"
LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id"
WHERE "comments"."id" IS NULL AND "comments"."spam" IS NULL)))
end
end

it 'doesn\'t permit anything if a nil is used as a condition for a has_many alongside other attributes' do
a1 = Article.create!
a2 = Article.create!
a2.comments = [Comment.create!(spam: true)]
a3 = Article.create!
a3.comments = [Comment.create!(spam: false)]

# if we are checking for `id: nil` and any other criteria, we should never return any Article.
# either the Article has Comments, which means `id: nil` fails.
# or the Article has no Comments, which means `spam: true` fails.
@ability.can :read, Article, comments: { id: nil, spam: true }

expect(@ability.can?(:read, a1)).to eq(false)
expect(@ability.can?(:read, a2)).to eq(false)
expect(@ability.can?(:read, a3)).to eq(false)

expect(Article.accessible_by(@ability)).to eq([])

if CanCan::ModelAdapters::ActiveRecordAdapter.version_greater_or_equal?('5.0.0')
expect(@ability.model_adapter(Article, :read)).to generate_sql(%(
SELECT "articles".*
FROM "articles"
WHERE "articles"."id" IN (SELECT "articles"."id" FROM "articles"
LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id"
WHERE "comments"."id" IS NULL AND "comments"."spam" = #{true_v})))
end
end

it 'doesn\'t permit if a nil is used as a condition for a has_many alongside other attributes - false case' do
a1 = Article.create!
a2 = Article.create!
a2.comments = [Comment.create!(spam: true)]
a3 = Article.create!
a3.comments = [Comment.create!(spam: false)]

# if we are checking for `id: nil` and any other criteria, we should never return any Article.
# either the Article has Comments, which means `id: nil` fails.
# or the Article has no Comments, which means `spam: false` fails.
@ability.can :read, Article, comments: { id: nil, spam: false }

expect(@ability.can?(:read, a1)).to eq(false)
expect(@ability.can?(:read, a2)).to eq(false)
expect(@ability.can?(:read, a3)).to eq(false)

expect(Article.accessible_by(@ability)).to eq([])

if CanCan::ModelAdapters::ActiveRecordAdapter.version_greater_or_equal?('5.0.0')
expect(@ability.model_adapter(Article, :read)).to generate_sql(%(
SELECT "articles".*
FROM "articles"
WHERE "articles"."id" IN (SELECT "articles"."id" FROM "articles"
LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id"
WHERE "comments"."id" IS NULL AND "comments"."spam" = #{false_v})))
end
end

it 'allows a nil to be used as a condition for a has_many when combined with other conditions' do
a1 = Article.create!
a2 = Article.create!
a2.comments = [Comment.create!(spam: true)]
a3 = Article.create!
a3.comments = [Comment.create!(spam: false)]

@ability.can :read, Article, comments: { spam: true }
@ability.can :read, Article, comments: { id: nil }

expect(@ability.can?(:read, a1)).to eq(true) # true because no comments
expect(@ability.can?(:read, a2)).to eq(true) # true because has comments but they have spam=true
expect(@ability.can?(:read, a3)).to eq(false) # false because has comments but none with spam=true

expect(Article.accessible_by(@ability).sort_by(&:id)).to eq([a1, a2].sort_by(&:id))

if CanCan::ModelAdapters::ActiveRecordAdapter.version_greater_or_equal?('5.0.0')
expect(@ability.model_adapter(Article, :read)).to generate_sql(%(
SELECT "articles".*
FROM "articles"
WHERE "articles"."id" IN (SELECT "articles"."id" FROM "articles"
LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id"
WHERE (("comments"."id" IS NULL) OR ("comments"."spam" = #{true_v})))))
end
end

it 'allows a nil to be used as a condition for a has_many alongside other attributes on the parent' do
a1 = Article.create!(secret: true)
a2 = Article.create!(secret: true)
a2.comments = [Comment.create!]
a3 = Article.create!(secret: false)
a3.comments = [Comment.create!]
a4 = Article.create!(secret: false)

@ability.can :read, Article, secret: true, comments: { id: nil }

expect(@ability.can?(:read, a1)).to eq(true)
expect(@ability.can?(:read, a2)).to eq(false)
expect(@ability.can?(:read, a3)).to eq(false)
expect(@ability.can?(:read, a4)).to eq(false)

expect(Article.accessible_by(@ability)).to eq([a1])

if CanCan::ModelAdapters::ActiveRecordAdapter.version_greater_or_equal?('5.0.0')
expect(@ability.model_adapter(Article, :read)).to generate_sql(%(
SELECT "articles".*
FROM "articles"
WHERE "articles"."id" IN (SELECT "articles"."id" FROM "articles"
LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id"
WHERE "articles"."secret" = #{true_v} AND "comments"."id" IS NULL)))
end
end

it 'allows an empty array to be used as a condition for a belongs_to; this never returns true' do
a1 = Article.create!
a2 = Article.create!
a2.project = Project.create!

@ability.can :read, Article, project_id: []

expect(@ability.can?(:read, a1)).to eq(false)
expect(@ability.can?(:read, a2)).to eq(false)

expect(Article.accessible_by(@ability)).to eq([])

if CanCan::ModelAdapters::ActiveRecordAdapter.version_greater_or_equal?('5.0.0')
expect(@ability.model_adapter(Article, :read)).to generate_sql(%(
SELECT "articles".*
FROM "articles"
WHERE 1=0))
end
end

context 'with namespaced models' do
before :each do
ActiveRecord::Schema.define do
Expand Down

0 comments on commit 8efb522

Please sign in to comment.