I am trying to create a scope that would provide me with all clients that made a purchase after they've :transitioned_to_maintenance (a term we use internally).
My models and scopes are organized as follows:
class Client < ApplicationRecord
has_many :program_transitions
has_many :purchases
scope :transitioned_to_maintenance, -> { where(id: ProgramTransition.to_maintenance.pluck(:client_id)) }
scope :has_purchases, -> { where(id: Purchase.pluck(:client_id)) }
end
class ProgramTransition < ApplicationRecord
belongs_to :client, required: true
scope :to_fm, -> { where(new_status: "full maintenance") }
scope :to_lm, -> { where(new_status: "limited maintenance") }
scope :to_maintenance, -> { to_fm.or(to_lm)}
end
class Purchase < ActiveRecord::Base
belongs_to :client
end
I could accomplish what I am trying to do by looping through the clients and selected those that meet my criteria, but I was hoping it'd be possible through a scope. Here it is as a model level function:
def self.purchased_after_maintenance
clients = Client.transitioned_to_maintenance.has_purchases
final = []
clients.each do |client|
min_date = client.program_transitions.to_maintenance.first.created_at
final << client if client.purchases.last.created_at >= ? min_date
end
end
Is this possible to do with a scope and without looping through all clients?
A couple of things before addressing the main question. Don't mean to be annoying and not address the main question first, but I thought this could be helpful.
Instead of scope :transitioned_to_maintenance, -> { where(id: ProgramTransition.to_maintenance.pluck(:client_id)) }, I would do this (see below). merge is really powerful and something we have been using a lot lately at our company.
scope :transitioned_to_maintenance, -> { joins(:program_transitions).merge(ProgramTransition.to_maintenance) }
Instead of doing scope :has_purchases, -> { where(id: Purchase.pluck(:client_id)) }, I would just do scope :has_purchases, -> { joins(:purchases) }, as the joins on purchases will only return clients that have associated purchases.
Regarding the main question, I can't think of a good way to do this with scopes, but it's probably possible. If I were you I would ask your data team what the SQL would look like for that query, and then try to figure out the active record needed to create it. However, if you stick with your way, here is a slight refactor I would recommend. Using select instead of each and eager loading to avoid n+1 queries.
def self.purchased_after_maintenance
Client.joins(:purchases, :program_transitions).includes(:purchases, :program_transitions).transitioned_to_maintenance.select do |client|
transition_date = client.program_transitions.to_maintenance.first.created_at
client.purchases.last.created_at >= transition_date
end
end
Hope this helps, and sorry I don't have the answer with the scopes.
Related
I'm reading the book 'Beginning Rails 6 - From Novice to Professional' (awesome book, btw), and I'm on a chapter about Advanced Active Record, great information, but one part of the text is driving me crazy, it works, but I don't get the explanation, could someone please clarify it to me?
As in a regular where method, you can use arrays as parameters. In fact, you can chain finder methods with other named scopes. You define the recent scope to give you articles recently published: first, you use the published named scope, and then you chain to it a where call
To me, the code example isn't exactly what text says (I've tried to find any erratas, with no success):
class Article < ApplicationRecord validates :title, :body, presence: true
belongs_to :user has_and_belongs_to_many :categories has_many :comments
scope :published, -> { where.not(published_at: nil) }
scope :draft, -> { where(published_at: nil) }
scope :recent, -> { where('articles.published_at > ?', 1.week.ago.to_date) }
def long_title
"#{title} - #{published_at}"
end end
I don't see the You define the recent scope to give you articles recently published: first, you use the published named scope, and then you chain to it a where call, plus, can't I just use scope :recent, -> { where('published_at > ?', 1.week.ago.to_date) } instead scope :recent, -> { where('articles.published_at > ?', 1.week.ago.to_date) } removing the articles from the where?
Thanks a lot!
So what the text tries to say are two things.
One is that you can use arrays in where and active record understands and translates it properly to SQL. For example Article.where(user_id: [1,2,3]) will find all articles where the user_id is either 1, 2 or 3.
The second thing they try to explain is that you can chain scopes. So in this case, what they try to explain is that you can do this: Article.recent.published or Article.recent.draft
For your last question, yes you can write it like you suggest and it would work :recent, -> { where('published_at > ?', 1.week.ago.to_date) }. The reason they use articles. is to ensure the scope works when you try to chain scopes with joins.
I have two models
class Project
has_one: user
end
class User
# Attributes
# active: Boolean
# under_18: Boolean
def can_work?
active? && under_18 == false
end
end
The logic for can_work?
if active is true and under_18 is false then they can work
I want to do something like this but it's not possible
Project.all.joins(:user).where('users.can_work? = ?', false)
Essentially what I'm looking for is to find all users who can't work
I know I can use Scope, but copying the logic that I specified above in scope is confusing.
Here's the scenario that I'm looking for
active | under_18
------------------
T T = F
T F = T
F T = F
F F = F
Thanks
As per Sergio Tulentsev's answer, you can't do it. But if you want it DRY so badly you can use scopes.
Scope definition alt. 1:
scope :can_work, -> { where active: true, under_18: false }
scope :cant_work, -> { where.not id: User.can_work.pluck(:id) }
Scope definition alt. 2:
scope :can_work, ->(t=true) { where(active: t).where.not(under_18: t) }
New can_work? method
def can_work?
User.can_work.pluck(:id).include?(id)
end
Now you can call:
Project.joins(:user).merge(User.cant_work)
Or
Project.joins(:user).merge(User.can_work(false))
PS. Good luck with the speed.
Try
Project.joins(:users).where(user: {under_18: <Bool>, active: <Bool>})
Insert the Boolean values (true or false) that you want in place of <Bool>s above.
Source
Side note
If users are workers then I suggest changing names in your schema to look like:
workers
id
project_id
under_18
active
1
1
TRUE
TRUE
...
...
...
...
projects
id
title
1
"lorem"
...
...
So the associations would be
class Project
has_one :worker
end
class Worker
belongs_to :project
end
And the query would then look like
Project.joins(:workers).where(worker: {under_18: <Bool>, active: <Bool>})
I think it makes more sense from a naming standpoint.
That is since users are oftentimes regarded as the owners in association relationships (i.e. a project would belong to a user, and a user would have many projects). Just a quick side suggestion.
Answer: Scope
Scopes are custom queries defined in Rails model inside method named scope
A scope can have two parameters, the name of the scope and lambda which contains the query.
Something like:
class User < ApplicationRecord
scope :is_active, -> { where(active: true) }
scope :under_18, -> { where(under_18: true) }
end
Scope returns ActiveRecord::Relation object. So scopes can be chained with multiple scopes like,
User.is_active.under_18
The scope is nothing but a method same as class methods. The only difference is that you get a guarantee of getting ActiveRecord::Relation as an output of scope. This helps you to write specific code and helps to reduce errors in the code.
Default Scope:
You can also add a default scope on any model which is implicitly applied to every query made on the respective model.
Be cautious while using this though as this might cause unpredictable results.
For example:
class User < ApplicationRecord
default_scope :is_active, -> { where(active: true) }
scope :under_18, -> { where(under_18: true) }
end
In the above case, User.first will return the first user who is active.
Benefits of Scope
Testable - The separate scopes are more testable now as they now follow the Single Responsibility Principle
Readable
DRY - Combining multiple scopes allows you not to repeat the code
User has many Posts
Post has many Comments
User has many Comments
class Post < ApplicationRecord
..stuff...
scope :created_by, ->(user) { where(creator: user) }
scope :with_comments_by, ->(user) { joins(:comments).where('comments.creator_id = ?'. user.id) }
##########========= this is my failure:
scope :related_to, ->(user) { created_by(user).or(with_comments_by(user) }
(not my real models, just sticking with SO basic app structure)
This last scope doesn't work, as is clearly noted:
The two relations must be structurally compatible, they must be scoping the same model, and they must differ only by WHERE or HAVING.
So, how do I get around this? (please don't say a messy, long SQL sentence)
I want to be able to call Posts.related_to(user) and get one ActiveRecord collection of all posts the user created or commented on.
I was headed down this path, but I know this is perverse:
class Post < ApplicationRecord
..stuff...
scope :created_by, ->(user) { where(creator: user) }
scope :with_comments_by, ->(user) { joins(:comments).where('comments.creator_id = ?'. user.id) }
##########========= this is my failure:
# scope :related_to, ->(user) { created_by(user).or(with_comments_by(user) }
def self.related_to(user)
ary = []
ary << Post.created_by(user).map(&:id)
ary << Post.with_comments_by(user).map(&:id)
Post.find(ary.uniq)
# so...bad...so yucky
end
Help me, SO community. I'm trapped in my own mind.
Your with_comments_by scope isn't quite what you want. That scope should be finding posts whose comments have a comment from user so you should be saying exactly that:
scope :with_comments_by, ->(user) { where(id: Comment.select(:post_id).where(creator_id: user.id)) }
You should be able to use that scope with your or in related_to without any complaints.
This version of with_comments_by will also neatly take care of the duplicate posts that your JOIN could produce if someone has commented multiple times on one post.
Imagine a Book and a Chapter model. Each Chapter belongs_to :book, and a Book has_many :chapters. We have scopes on the Chapter, for example :very_long returns chapters with over 300 pages.
Many times we want to get all books with any chapters over 300 pages. The way we usually achieve this is like so:
# book.rb
scope :has_very_long_chapter, -> { where(id: Chapter.very_long.select(:book_id) }
However, as you can imagine, it gets pretty tedious to proxy the scope every time we want to filter Books by chapter scopes. Is there any more idiomatic or cleaner way to achieve this?
To get those books you can use ActiveRecord::SpawnMethods#merge, and you don't have to use another scope:
Book.joins(:chapters).merge(Chapter.very_long)
I think one thing you could do would be to use joins/merge
class Book < ARBase
scope :with_long_chapters, ->{ joins(:chapter).merge(Chapter.very_long) }
end
class Chapter < ARBase
scope :very_long, -> { logic for querying chapters with over 300 pages }
end
EDIT(#2): A more reusable scope
class Book < ARBase
scope :by_chapter_page_count, ->(pages){ joins(:chapter).merge(Chapter.by_page_count pages) }
scope :with_climax, -> { joins(:chapter).merge(Chapter.by_category :climax) }
scope :long_with_climax, -> { by_chapter_page_count(400).with_climax }
end
class Chapter < ARBase
scope :by_page_count, ->(pages) { where("pages > ?", pages }
scope :by_category, ->(category) { where category: category }
end
Book.by_chapter_page_count(100)
You can get fairly creative with how you write your scopes
I have several objects that all have an approved field.
What would be the best way to implement a scope to use across all models?
For example, I have a sighting object and a comment object. They both have to be approved by an admin before being availalbe to the public.
So how can I create a scope that returns comment.approved as well as sighting.approved respectively without repeating it on each model? Is this where concerns comes into play?
While just declaring a scope in each model is fine if you just want the scoping functionality. Using an ActiveSupport::Concern will give you the ability to add additional methods as well if that's something you think is going to happen. Here's an example:
# /app/models/concerns/approved.rb
module Approved
extend ActiveSupport::Concern
included do
default_scope { where(approved: false) }
scope :approved, -> { where(approved: true) }
end
def unapprove
update_attribute :approved, false
end
end
class Sighting < ActiveRecord::Base
include Approved
end
class Comment < ActiveRecord::Base
include Approved
end
Then you can make calls like Sighting.approved and Comment.approved to get the appropriate list of approved records. You also get the unapprove method and can do something like Comment.approved.first.unapprove.
In this example, I've also included default_scope which will mean that calls like Sighting.all or Comment.all will return only unapproved items. I included this just as an example, it may not be applicable to your implementation.
Although I've noticed the scope pulled from the concerns needs to be the last scope when concatenating scopes. I'm not quite sure why.
Comment.existing.approved
When I tried it as:
Comment.approved.existing
It failed silently.
And I take that back. I was migrating older code and using scopes with conditions instead of lambdas. When I replaced :conditions the scope order no longer mattered.
scope :existing, -> { where("real = 1") }
replaced
scope :existing, :conditions => "real = 1"