Skip to content

Instantly share code, notes, and snippets.

@oboxodo
Forked from coorasse/main.rb
Last active January 23, 2023 00:13
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save oboxodo/180703cff6182cdb63a9ba4091b59c26 to your computer and use it in GitHub Desktop.
Save oboxodo/180703cff6182cdb63a9ba4091b59c26 to your computer and use it in GitHub Desktop.
CanCanCan Issue
# This file serves the purpose of starting a discussion on CanCanCan to try and
# avoid instantiating in memory a lot of uneeded objects when calling `can?`.
# I think in many cases it should be possible to leverage `accessible_by` to
# resolve the ability using an optimized SQL query instead.
# This file started life as a copy of https://gist.github.com/coorasse/3f00f536563249125a37e15a1652648c
begin
require 'bundler/inline'
rescue LoadError => e
$stderr.puts 'Bundler version 1.10 or later is required. Please update your Bundler'
raise e
end
gemfile(true) do
source 'https://rubygems.org'
gem 'rails', '7.0.4' # use correct rails version
gem 'cancancan', '3.4.0' # use correct cancancan version
gem 'sqlite3' # use another DB if necessary
end
require 'active_record'
require 'cancancan'
require 'cancan/model_adapters/sti_normalizer.rb'
require 'cancan/model_adapters/conditions_normalizer.rb'
require 'cancan/model_adapters/conditions_extractor.rb'
require 'cancan/model_adapters/strategies/base.rb'
require 'cancan/model_adapters/strategies/left_join.rb'
require 'cancan/model_adapters/strategies/subquery.rb'
require 'cancan/model_adapters/active_record_adapter'
require 'cancan/model_adapters/active_record_4_adapter'
require 'cancan/model_adapters/active_record_5_adapter'
require 'minitest/autorun'
require 'logger'
# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
ActiveRecord::Base.logger = Logger.new(STDOUT)
# create your tables here
ActiveRecord::Schema.define do
create_table :books, force: true do |t|
t.integer :user_id
t.integer :author_id
end
create_table :users, force: true do |t|
t.string :name
end
create_table :authors, force: true do |t|
t.string :name
end
end
class Book < ActiveRecord::Base
belongs_to :author
belongs_to :user
end
class User < ActiveRecord::Base
has_many :books
end
class Author < ActiveRecord::Base
has_many :books
end
class Ability
include CanCan::Ability
def initialize(user)
can :read, Book, user: user
can :read, Author, books: { user: user }
end
# This is IMHO the most optimized implementation of `can?` for cases
# when `accessible_by` can be used.
def optimized_can?(action, subject)
subject.class.accessible_by(self, action).exists?(subject.id)
end
end
class BugTest < Minitest::Test
def test_bug
user1 = User.create!
user2 = User.create!
author1 = Author.create!
author2 = Author.create!
books = Book.create([{user: user1, author: author1}, {user: user2, author: author2}])
ability1 = Ability.new(user1)
books_accessible_by_user_1 = Book.accessible_by(ability1, :read)
assert_equal 1, books_accessible_by_user_1.count
assert ability1.can?(:read, books_accessible_by_user_1.first)
authors_accessible_by_user_1 = Author.accessible_by(ability1, :read)
assert_equal 1, authors_accessible_by_user_1.count
assert_equal author1, authors_accessible_by_user_1.first
# `optimized_can?` leverages `accessible_by` to generate an optimized SQL to determine
# the ability result, without instantiating all the author's books in memory:
# SELECT 1 AS one FROM "authors" WHERE "authors"."id" IN (
# SELECT "authors"."id" FROM "authors"
# LEFT OUTER JOIN "books" ON "books"."author_id" = "authors"."id"
# WHERE "books"."user_id" = ?
# ) AND "authors"."id" = ? LIMIT ? [["user_id", 1], ["id", 1], ["LIMIT", 1]]
assert ability1.optimized_can?(:read, author1)
assert !ability1.optimized_can?(:read, author2)
assert !author1.books.loaded? # good!
# `can?` recursivelly calls all the required associations effectivelly instantiating
# lots of probably-unneeded objects in memory and looping through them.
# SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 1]]
# SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
assert ability1.can?(:read, author1)
assert !ability1.can?(:read, author2)
# The following fails because the call to `can?` above loaded the whole collection.
# This is quite bad because `author1.books` could have millions of records.
# Why can't `can?` behave like the custom `optimized_can?` defined above when all the
# subject abilities have been defined with the hash conditionals?
assert !author1.books.loaded? # bad! This fails.
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment