How to isolate a query inside a scope from pundit policy scope? - ruby-on-rails

I'm using Rails 5 + Pundit gem and trying to fetch some chats with policy scope and model scope. Model scope has a query inside it and the problem is that policy scope applies to this inner query. The question is how to isolate the query from outer scope? Here's some code:
# model
scope :with_user, ->(user_id=nil) {
user_id ? where(chats: { id: User.find(user_id).chats.ids }) : all
}
# policy
class Scope < Scope
def resolve
if user.admin?
scope.all
else
scope.joins(:chat_users).where(chat_users: { user_id: user.id })
end
end
end
So I decided to output the inner sql query, which should get user chats' ids from the scope. I updated the model scope:
scope :with_user, ->(user_id=nil) {
puts User.find(user_id).chats.to_sql
where(chats: { id: User.unscoped.find(user_id).chats.ids } )
}
and here are results:
when I run ChatPolicy::Scope.new(User.first, Chat).resolve.with_user(358) I get:
SELECT "chats".* FROM "chats" INNER JOIN "chat_users"
"chat_users_chats" ON "chat_users_chats"."chat_id" = "chats"."id"
INNER JOIN "chat_users" ON "chats"."id" = "chat_users"."chat_id" WHERE
(chat_users.user_id = 350) AND "chat_users"."user_id" = 358
When I run Chat.with_user(358) I get:
SELECT "chats".* FROM "chats" INNER JOIN "chat_users" ON "chats"."id"
= "chat_users"."chat_id" WHERE "chat_users"."user_id" = 358
It generates the correct query if I run it without policy scope. Is there a workaround?

This is a Community Wiki answer replacing the answer having been edited into the original question as recommended by Meta.
This has been solved by the OP with a different unscoped model scope:
scope :with_user, ->(user_id=nil) {
user_id ? where(chats: { id: Chat.unscoped.joins(:chat_users).where(chat_users: { user_id: user_id }).ids } ) : all
}

Related

Rails active record where chaining losing scope

Model Food has scope expired:
Food.rb
class Food < ApplicationRecord
default_scope { where.not(status: 'DELETED') }
scope :expired, -> { where('exp_date <= ?', DateTime.now) }
belongs_to :user
end
In my controller I'm chaining where conditions to filter foods by user and status:
query_type.rb
def my_listing_connection(filter)
user = context[:current_user]
scope = Food.where(user_id: user.id)
if filter[:status] == 'ARCHIVED'
# Line 149
scope = scope.where(
Food.expired.or(Food.where(status: 'COMPLETED'))
)
else
scope = scope.where(status: filter[:status])
end
scope.order(created_at: :desc, id: :desc)
# LINE 157
scope
end
Here is the rails log:
Food Load (2.7ms) SELECT `foods`.* FROM `foods` WHERE `foods`.`status` !=
'DELETED'
AND ((exp_date <= '2020-07-02 09:58:16.435609') OR `foods`.`status` = 'COMPLETED')
↳ app/graphql/types/query_type.rb:149
Food Load (1.6ms) SELECT `foods`.* FROM `foods` WHERE `foods`.`status` != 'DELETED'
AND `foods`.`user_id` = 1 ORDER BY `foods`.`created_at` DESC, `foods`.`id` DESC
↳ app/graphql/types/query_type.rb:157
Why does active records query loses expired scope (and a condition) in line 157?
It is ignored because where doesn't expect scopes like that. But you can use merge instead. Replace
scope = scope.where(
Food.expired.or(Food.where(status: 'COMPLETED'))
)
with
scope = scope.merge(Food.expired)
.or(Food.where(status: 'COMPLETED'))
or
scope = scope.where(status: 'COMPLETED').or(Food.expired)

Scope Order by Count with Conditions Rails

I have a model Category that has_many Pendencies. I would like to create a scope that order the categories by the amount of Pendencies that has active = true without excluding active = false.
What I have so far is:
scope :order_by_pendencies, -> { left_joins(:pendencies).group(:id).order('COUNT(pendencies.id) DESC')}
This will order it by number of pendencies, but I want to order by pendencies that has active = true.
Another try was:
scope :order_by_pendencies, -> { left_joins(:pendencies).group(:id).where('pendencies.active = ?', true).order('COUNT(pendencies.id) DESC')}
This will order by number of pendencies that has pendencies.active = true, but will exclude the pendencies.active = false.
Thank you for your help.
I guess you want to sort by the amount of active pendencies without ignoring categories that have no active pendencies.
That would be something like:
scope :order_by_pendencies, -> {
active_count_q = Pendency.
group(:category_id).
where(active: true).
select(:category_id, "COUNT(*) AS count")
joins("LEFT JOIN (#{active_count_q.to_sql}) AS ac ON ac.category_id = id").
order("ac.count DESC")
}
The equivalent SQL query:
SELECT *, ac.count
FROM categories
LEFT JOIN (
SELECT category_id, COUNT(*) AS count
FROM pendencies
GROUP BY category_id
WHERE active = true
) AS ac ON ac.category_id = id
ORDER BY ac.count DESC
Note that if there are no active pendencies for a category, the count will be null and will be added to the end of the list.
A similar subquery could be added to sort additionally by the total amount of pendencies...
C# answer as requested:
method() {
....OrderBy((category) => category.Count(pendencies.Where((pendency) => pendency.Active))
}
Or in straight SQL:
SELECT category.id, ..., ActivePendnecies
FROM (SELECT category.id, ..., count(pendency) ActivePendnecies
FROM category
LEFT JOIN pendency ON category.id = pendency.id AND pendnecy.Active = 1
GROUP BY category.id, ...) P
ORDER BY ActivePendnecies;
We have to output ActivePendnecies in SQL even if the code will throw it out because otherwise the optimizer is within its rights to throw out the ORDER BY.
For now I developed the following (it's working, but I believe that it's not the best way):
scope :order_by_pendencies, -> { scoped = Category.left_joins(:pendencies)
.group(:id)
.order('COUNT(pendencies.id) DESC')
.where('pendencies.active = ?', true)
all = Category.all
(scoped + all).uniq}

Find record which doesn't have any associated records with a specific value

I have a couple of models: User and UserTags
A User has_many UserTags
A UserTag belongs_to User
I am trying to find all the Users which don't have a UserTag with name 'recorded' (so I also want users which don't have any tags at all). Given a users relation, I have this:
users.joins("LEFT OUTER JOIN user_tags ON user_tags.user_id = users.id AND user_tags.name = 'recorded'").
where(user_tags: { id: nil })
Is there any other better or more Railsy way of doing this?
Try this:
users.joins("LEFT OUTER JOIN user_tags ON user_tags.user_id=users.id").where("user_tags.name != ? OR user_tags.id is NULL", 'recorded')
This one should work:
users.joins(:user_tags).where.not(user_tags: { name: 'recorded' })
Joins not eager load nested model you should use "Includes or eage_load"
users.eager_load(:user_tags).where.not(user_tags: { name: 'recorded' })
This will use left outer join and you can update your query in where clause.
Same as
users.includes(:user_tags).where.not(user_tags: { name: 'recorded' })
Try this, It will return the users with 0 user_tags :
users = users.joins(:user_tag).where("users.id IN (?) OR user_tags.name != ?",User.joins(:user_tag).group("users.id").having('count("user_tag.user_id") = 0'), "recorded")
Hey you can simply use includes for outer join as user_tags.id is null returns all your record not having user_tags and user_tags.name != 'recorded' returns record having user_tag name is not recorded
users.includes(:user_tags).where("user_tags.id is null or user_tags.name != ?","recorded")
Or you can also used using not in clause as but it is not optimised way for given query:
users.includes(:user_tags).where("users.id not in (select user_id from user_tags) or user_tags.name != ?","recorded")

Use associated model scope

If I have a model called User that has_many :games and games has_many :events- How can I get all the users which do not have any games OR have any games with a suspended event.
Here is what I have tried:
Example:
User class:
scope :with_no_games -> {
joins("LEFT OUTER JOIN games g on g.user_id = users.id").where("games.id IS NULL")
}
scope :with_suspended_games_or_no_games -> {
with_no_games.merge(Game.suspended) # <- this doesn't work
}
Game class:
scope :suspended -> {
joins("INNER JOIN events e ON event.game_id = games.id AND event.name = 'suspended'")
}
If I run User.with_suspended_games_or_no_games I get the following SQL statement:
SELECT \"users\".* FROM \"users\" LEFT OUTER JOIN games ON games.user_id = users.id INNER JOIN events ON events.game_id = games.id AND events.name = 'suspended' WHERE (games.user_id IS NULL)"
Which is not what I am looking for. What am I missing?
I think you are looking for
scope :with_suspended_games_or_no_games -> {
with_no_games.joins(:games).merge(Game.suspended)
}
to see the sql try running the following from a rails console:
User.with_suspended_games_or_no_games.to_sql
bonus
you could also write your scopes like:
User
scope :with_no_games -> { joins(:games).where(games: {id: nil}) }
scope :with_suspended_games_or_no_games -> { joins(:games).with_no_games.merge(Game.suspended) }
Game
scope :suspended -> { joins(:events).where(events: {name: 'suspended'}) }

default_scope and associations

Suppose I have a Post model, and a Comment model. Using a common pattern, Post has_many Comments.
If Comment has a default_scope set:
default_scope where("deleted_at IS NULL")
How do I easily retrieve ALL comments on a post, regardless of scope?
This produces invalid results:
Post.first.comments.unscoped
Which generates the following queries:
SELECT * FROM posts LIMIT 1;
SELECT * FROM comments;
Instead of:
SELECT * FROM posts LIMIT 1;
SELECT * FROM comments WHERE post_id = 1;
Running:
Post.first.comments
Produces:
SELECT * FROM posts LIMIT 1;
SELECT * FROM comments WHERE deleted_at IS NULL AND post_id = 1;
I understand the basic principle of unscoped removing all existing scopes, but shouldn't it be aware and to keep the association scope?
What is the best way to pull ALL comments?
For some strange reasons,
Comment.unscoped { Post.last.comments }
includes the default_scope of Comment,
however,
Comment.unscoped { Post.last.comments.to_a }
Comment.unscoped { Post.last.comments.order }
do not include the default_scope of Comment.
I experienced this in a rails console session with Rails 3.2.3.
with_exlusive_scope is deprecated as of Rails 3. See this commit.
Before (Rails 2):
Comment.with_exclusive_scope { Post.find(post_id).comments }
After (Rails 3):
Comment.unscoped { Post.find(post_id).comments }
Rails 4.1.1
Comment.unscope(where: :deleted_at) { Post.first.comments }
Or
Comment.unscoped { Post.first.comments.scope }
Note that I added .scope, it seems like this block should return kind of ActiveRecord_AssociationRelation (what .scope does) not ActiveRecord_Associations_CollectionProxy (without a .scope)
This is indeed a very frustrating problem which violates the principle of least surprise.
For now, you can just write:
Comment.unscoped.where(post_id: Post.first)
This is the most elegant/simple solution IMO.
Or:
Post.first.comments.scoped.tap { |rel| rel.default_scoped = false }
The advantage of the latter:
class Comment < ActiveRecord::Base
# ...
def self.with_deleted
scoped.tap { |rel| rel.default_scoped = false }
end
end
Then you can make fun things:
Post.first.comments.with_deleted.order('created_at DESC')
Since Rails 4, Model.all returns an ActiveRecord::Relation , rather than an array of records.
So you can (and should) use all instead of scoped:
Post.first.comments.all.tap { |rel| rel.default_scoped = false }
How about this?
# Use this scope by default
scope :active, -> { where(deleted_at: nil) }
# Use this whenever you want to include all comments regardless of their `deleted_at` value
scope :with_soft_deleted, -> { unscope(where: :deleted_at)
default_scope, -> { active }
post.comments would fire this query:
SELECT "comments".* FROM "comments" WHERE "comments"."deleted_at" IS NULL AND "comments"."post_id" = $1;
post.comments.with_soft_deleted would send this:
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1;
class Comment
def post_comments(post_id)
with_exclusive_scope { find(all, :conditions => {:post_id => post_id}) }
end
end
Comment.post_comments(Post.first.id)

Resources