Last active
February 10, 2023 17:33
-
-
Save pjambet/6f72ca61020242de08fa36e4541a0b61 to your computer and use it in GitHub Desktop.
Scoping issues with default scopes and explicit scopes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
require "bundler/inline" | |
require "debug" | |
gemfile(true) do | |
source "https://rubygems.org" | |
git_source(:github) { |repo| "https://github.com/#{repo}.git" } | |
gem "rails", github: "rails/rails", branch: "main" | |
gem "sqlite3" | |
end | |
require "active_record" | |
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) | |
ActiveRecord::Schema.define do | |
create_table :posts, force: true do |t| | |
end | |
create_table :comments, force: true do |t| | |
t.integer :post_id | |
t.boolean :deleted, default: false | |
end | |
end | |
class SQLCounter | |
class << self | |
attr_accessor :ignored_sql, :log, :log_all | |
def clear_log; self.log = []; self.log_all = []; end | |
end | |
clear_log | |
def call(name, start, finish, message_id, values) | |
return if values[:cached] | |
sql = values[:sql] | |
self.class.log_all << sql | |
self.class.log << sql unless ["SCHEMA", "TRANSACTION"].include? values[:name] | |
end | |
end | |
ActiveSupport::Notifications.subscribe("sql.active_record", SQLCounter.new) | |
def capture_sql(output = nil) | |
ActiveRecord::Base.connection.materialize_transactions | |
SQLCounter.clear_log | |
yield | |
log = SQLCounter.log.dup.first # I only care about the first here | |
log | |
ensure | |
log = SQLCounter.log.dup.first if log.nil? | |
output.concat(log) if output | |
end | |
class Post < ActiveRecord::Base | |
has_many :comments | |
end | |
class Comment < ActiveRecord::Base | |
belongs_to :post | |
default_scope -> { where(deleted: false) } | |
default_scope -> { where(post_id: 1) }, all_queries: true | |
end | |
class CommentWithOnlyDefaultScopeWithoutAllQueries < ActiveRecord::Base | |
self.table_name = "comments" | |
belongs_to :post | |
default_scope -> { where(deleted: false) } | |
end | |
class CommentWithOnlyDefaultScopeWithAllQueries < ActiveRecord::Base | |
self.table_name = "comments" | |
belongs_to :post | |
default_scope -> { where(post_id: 1) }, all_queries: true | |
end | |
class BugTest < Minitest::Test | |
def setup | |
if Post.find_by(id: 1) # only run this before once | |
@post1 = Post.find(1) | |
@comment1 = Comment.unscoped.find(1) | |
@post2 = Post.find(2) | |
@comment2 = Comment.unscoped.find(2) | |
else | |
@post1 = Post.create! | |
@comment1 = @post1.comments.create! | |
@post2 = Post.create! | |
@comment2 = @post2.comments.create! | |
end | |
end | |
def test_scope_without_all_queries_is_ignored_on_reload_on_a_model_without_default_scopes | |
# Expected behavior: scope not flagged with all_queries, is ignored, currently works: | |
assert Post.where("1=2").scoping { @post1.reload } | |
end | |
def test_scope_with_all_queries_is_applied_on_reload_on_a_model_without_default_scopes | |
# Expected behavior: scope flagged with all_queries, is applied, currently works: | |
assert_raises(ActiveRecord::RecordNotFound) { Post.where("1=2").scoping(all_queries: true) { @post1.reload } } | |
end | |
### | |
def test_default_scope_with_all_queries_is_applied_on_reload_on_a_model_with_both_default_scopes_with_and_without_all_queries | |
# Expected behavior: default scope is flagged with all_queries, scope is applied, currently works: | |
sql = capture_sql { assert @comment1.reload } # where post_id = 1 matches that row | |
assert_match(/WHERE "comments"."post_id" = \? AND "comments"."id" = \? LIMIT \?\Z/, sql) | |
sql = +"" | |
assert_raises(ActiveRecord::RecordNotFound) { capture_sql(sql) { @comment2.reload } } # where post_id = 1 doesn't match that row | |
assert_match(/WHERE "comments"."post_id" = \? AND "comments"."id" = \? LIMIT \?\Z/, sql) | |
end | |
def test_scope_without_all_queries_is_ignored_on_reload_on_a_model_with_both_default_scopes_with_and_without_all_queries | |
# skip("doesnt work") | |
# Expected behavior: only the default scope flagged with all_queries: true is applied, the other ones, on deleted = false and 1=1, are not, does not work: | |
sql = capture_sql { Comment.where("1=1").scoping { @comment1.reload } } | |
assert_match(/WHERE "comments"."post_id" = \? AND "comments"."id" = \? LIMIT \?\Z/, sql) # fails because 1=1 & deleted = false are incorrectly applied | |
end | |
def test_scope_with_all_queries_is_applied_but_only_default_scope_with_all_queries_on_reload_on_a_model_with_both_default_scopes_with_and_without_all_queries | |
# skip("doesnt work") | |
# Expected behavior: only the default scope flagged with all_queries: true is applied, does not work: | |
sql = capture_sql { Comment.where("1=1").scoping(all_queries: true) { @comment1.reload } } | |
assert_match(/WHERE "comments"."post_id" = \? AND \(1=1\) AND "comments"."id" = \? LIMIT \?\Z/, sql) # fails because deleted = false is incorrectly applied | |
end | |
def test_scope_without_all_queries_is_ignored_on_reload_on_a_model_with_only_a_default_scope_without_all_queries | |
# Expected behavior: only the default scope flagged with all_queries: true is applied, currently works: | |
comment1 = CommentWithOnlyDefaultScopeWithoutAllQueries.find(1) | |
sql = capture_sql { CommentWithOnlyDefaultScopeWithoutAllQueries.where("1=2").scoping { comment1.reload } } | |
assert_match(/WHERE "comments"."id" = \? LIMIT \?\Z/, sql) | |
end | |
def test_scope_with_all_queries_is_applied_on_reload_on_a_model_with_only_a_default_scope_without_all_queries | |
# skip("doesnt work") | |
# Expected behavior: only the explicit scope flagged with all_queries: true is applied, does not work: | |
comment1 = CommentWithOnlyDefaultScopeWithoutAllQueries.find(1) | |
sql = capture_sql { CommentWithOnlyDefaultScopeWithoutAllQueries.where("1=1").scoping(all_queries: true) { comment1.reload } } | |
assert_match(/WHERE \(1=1\) AND "comments"."id" = \? LIMIT \?\Z/, sql) # Fails because deleted = false is incorrectly included | |
end | |
def test_scope_without_all_queries_is_ignored_on_reload_on_a_model_with_only_a_default_scope_with_all_queries | |
# skip("does not work") | |
# Expected behavior: only the default scope flagged with all_queries: true is applied, does not work: | |
comment1 = CommentWithOnlyDefaultScopeWithAllQueries.find(1) | |
sql = capture_sql { CommentWithOnlyDefaultScopeWithAllQueries.where("1=1").scoping { comment1.reload } } | |
assert_match(/WHERE "comments"."post_id" = \? AND "comments"."id" = \? LIMIT \?\Z/, sql) # Fails because 1=1 is incorrectly included | |
end | |
def test_scope_with_all_queries_is_applied_on_reload_on_a_model_with_only_a_default_scope_with_all_queries | |
# Expected behavior: the default scope flagged with all_queries: true and the explicit scope flagged with all_queries: true are applied, works: | |
comment1 = CommentWithOnlyDefaultScopeWithAllQueries.find(1) | |
sql = capture_sql { CommentWithOnlyDefaultScopeWithAllQueries.where("1=1").scoping(all_queries: true) { comment1.reload } } | |
assert_match(/WHERE "comments"."post_id" = \? AND \(1=1\) AND "comments"."id" = \? LIMIT \?\Z/, sql) | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment