So I'm new to scopes, and don't really understand them well.Let's say I have 2 models, Project and Ticket:
class Project < ActiveRecord::Base
has_many :tickets
end
class Ticket < ActiveRecord::Base
belongs_to :project
end
I'm used to code like this to access associated data from tickets:
Project.find(1).tickets.each do |ticket|
puts ticket.name
end
I created new scope:
scope :default, -> { where(default: true) }
And now when I use Project.default I get back ActiveRecord::Relation and don't have a clue how to access associated tickets ?
Project.default will indeed return an ActiveRecord::Relation, which is a 'to be triggered' query. The query will be triggered once you start looping etc, this is sort of transparent to you.
If you want to get tickets from the projects, first I recommend you include them in your query to avoid N+1. Do it this way:
projects = Project.default.includes(:tickets)
Then to access tickets of a particular project:
project = projects.first
project.tickets
If you want a method to always return a single object:
class Project < ActiveRecord::Base
has_many :tickets
def self.get_default_with_tickets
Project.where(default: true).includes(:tickets).first
end
end
then:
Project.get_default_with_tickets #=> your_project
Be sure to handle the cases:
when there is more than one match
when there is no match
A scope is basically just a class method (one which fires on a non-intialized model):
class Project < ActiveRecord::Base
has_many :tickets
scope :defaults, -> { where(default: true) }
end
This means if you do this:
#defaults = Project.defaults
... you get all the project objects back which have the attribute default as true
This is the same as this:
class Project < ActiveRecord::Base
has_many :tickets
def self.defaults
where(default: true)
end
end
Error
The reason you're getting a relation is because when you use where, you're basically getting back an "array" of data (as opposed to just a single record). If you .each through the data or just return .first, you'll get an actual object which you can output:
#defaults = Project.defaults
#defaults.each do |project|
project.tickets #-> associated tickets
end
Use all or first to complete the query.
#get all default projects
Project.default.all
#get the first default project
Project.default.first
Related
I have an activerecord class method scope that returns all when the scope should remain unchanged. However I would expect it to use the counter cache when chaining size to the all scope. Here is an example:
class Post < ApplicationRecord
has_many :comments
end
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
def self.filtered(only_approved:)
if only_approved
where(approved: true)
else
all
end
end
end
# This does not use the counter cache but should since the scope is unchanged
Post.first.comments.filtered(only_approved: false).size
So it looks like Post.comments.size triggers the counter cache while Post.comments.all.size does not. Is there a way around this?
This happens because of how the counter_cache works. It needs 2 things:
Add the counter_cache: true to the belonging model (Comment)
Add a column comments_count to the having model (Post)
The column added to the Post model gets updated everytime you create or destroy a model so it will count all existing records on the table. This is the reason why it won't work on a scope (a scope might be useful to filter the resulting records, but the actual column comments_count is still counting the whole table).
As a workaround I'd suggest you to take a look at and see if it can be used for your usecase https://github.com/magnusvk/counter_culture.
From their own repo:
class Product < ActiveRecord::Base
belongs_to :category
scope :awesomes, ->{ where "products.product_type = ?", 'awesome' }
scope :suckys, ->{ where "products.product_type = ?", 'sucky' }
counter_culture :category,
column_name: proc {|model| "#{model.product_type}_count" },
column_names: -> { {
Product.awesomes => :awesome_count,
Product.suckys => :sucky_count
} }
end
The only way I found to deal with this is to pass the scope to the class method and return it if no additional scope is to be added. It's not as clean but it works. Here is the updated code:
class Post < ApplicationRecord
has_many :comments
end
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
def self.filtered(scope:, only_approved:)
if only_approved
scope.where(approved: true)
else
scope
end
end
end
# This works with counter cache if the scope is returned as is
Comment.filtered(scope: Post.first.comments, only_approved: false).size
Models:
class Audio < ActiveRecord::Base
has_many :tests, as: :item
end
class Video < ActiveRecord::Base
has_many :tests, as: :item
end
class Test < ActiveRecord::Base
belongs_to :user
belongs_to :item, polymorphic: true
end
class User < ActiveRecord::Base
has_many :tests
def score_for(item)
return 0 unless tests.where(item: item).any?
tests.where(item: item).last.score
end
end
Serializers:
class VideoSerializer < ActiveModel::Serializer
attributes :id, :name
attribute(:score) { user.score_for(object) }
def user
instance_options[:user]
end
end
I try serialise lot of Video objects like this, but N+1 coming:
options = { each_serializer: VideoSerializer, user: User.last }
videos = ActiveModelSerializers::SerializableResource.new(Video.all, options).serializable_hash
If I try this, empty array returned(looks like videos not has tests for this user):
options = { each_serializer: VideoSerializer, user: User.last }
videos = ActiveModelSerializers::SerializableResource.new(Video.includes(:tests).where(tests: {user: User.last}), options).serializable_hash
How I can organise serialisation w/o N+1 queries problem.
You cannot avoid an N+1 query if you are using a method that triggers another SQL query (in this case where).
The method score_for does another query (or 2, which would definitely need refactoring) when you invoke the relation with where.
One way you could change this method would be not to use relation methods but array methods over already loaded relations. This is very inefficient for memory but much less heavy on DB.
def score_for(item)
tests.sort_by&:created_at).reverse.find { |test| test.user_id == id }&.score.to_f
end
You would need to load the video with its tests and the user.
Let's keep this simple. Let's say I have a User model and a Post model:
class User < ActiveRecord::Base
# id:integer name:string deleted:boolean
has_many :posts
end
class Post < ActiveRecord::Base
# id:integer user_id:integer content:string deleted:boolean
belongs_to :user
end
Now, let's say an admin wants to "delete" (hide) a post. So basically he, through the system, sets a post's deleted attribute to 1. How should I now display this post in the view? Should I create a virtual attribute on the post like this:
class Post < ActiveRecord::Base
# id:integer user_id:integer content:string deleted:boolean
belongs_to :user
def administrated_content
if !self.deleted
self.content
else
"This post has been removed"
end
end
end
While that would work, I want to implement the above in a large number of models, and I can't help feeling that copy+pasting the above comparative into all of my models could be DRYer. A lot dryer.
I also think putting a deleted column in every single deletable model in my app feels a bit cumbersome too. I feel I should have a 'state' table. What are your thoughts on this:
class State
#id:integer #deleted:boolean #deleted_by:integer
belongs_to :user
belongs_to :post
end
and then querying self.state.deleted in the comparator? Would this require a polymorphic table? I've only attempted polymorphic once and I couldn't get it to work. (it was on a pretty complex self-referential model, mind). And this still doesn't address the problem of having a very, very similar class method in my models to check if an instance is deleted or not before displaying content.
In the deleted_by attribute, I'm thinking of placing the admin's id who deleted it. But what about when an admin undelete a post? Maybe I should just have an edited_by id.
How do I set up a dependent: :destroy type relationship between the user and his posts? Because now I want to do this: dependent: :set_deleted_to_0 and I'm not sure how to do this.
Also, we don't simply want to set the post's deleted attributes to 1, because we actually want to change the message our administrated_content gives out. We now want it to say, This post has been removed because of its user has been deleted. I'm sure I could jump in and do something hacky, but I want to do it properly from the start.
I also try to avoid gems when I can because I feel I'm missing out on learning.
I usually use a field named deleted_at for this case:
class Post < ActiveRecord::Base
scope :not_deleted, lambda { where(deleted_at: nil) }
scope :deleted, lambda { where("#{self.table_name}.deleted_at IS NOT NULL") }
def destroy
self.update(deleted_at: DateTime.current)
end
def delete
destroy
end
def deleted?
self.deleted_at.present?
end
# ...
Want to share this functionnality between multiple models?
=> Make an extension of it!
# lib/extensions/act_as_fake_deletable.rb
module ActAsFakeDeletable
# override the model actions
def destroy
self.update(deleted_at: DateTime.current)
end
def delete
self.destroy
end
def undestroy # to "restore" the file
self.update(deleted_at: nil)
end
def undelete
self.undestroy
end
# define new scopes
def self.included(base)
base.class_eval do
scope :destroyed, where("#{self.table_name}.deleted_at IS NOT NULL")
scope :not_destroyed, where(deleted_at: nil)
scope :deleted, lambda { destroyed }
scope :not_deleted, lambda { not_destroyed }
end
end
end
class ActiveRecord::Base
def self.act_as_fake_deletable(options = {})
alias_method :destroy!, :destroy
alias_method :delete!, :delete
include ActAsFakeDeletable
options = { field_to_hide: :content, message_to_show_instead: "This content has been deleted" }.merge!(options)
define_method options[:field_to_hide].to_sym do
return options[:message_to_show_instead] if self.deleted_at.present?
self.read_attribute options[:field_to_hide].to_sym
end
end
end
Usage:
class Post < ActiveRecord::Base
act_as_fake_deletable
Overwriting the defaults:
class Book < ActiveRecord::Base
act_as_fake_deletable field_to_hide: :title, message_to_show_instead: "This book has been deleted man, sorry!"
Boom! Done.
Warning: This module overwrite the ActiveRecord's destroy and delete methods, which means you won't be able to destroy your record using those methods anymore. Instead of overwriting you could create a new method, named soft_destroy for example. So in your app (or console), you would use soft_destroy when relevant and use the destroy/delete methods when you really want to "hard destroy" the record.
Imagine the scenario...
#models/user.rb
class User < ActiveRecord::Base
has_many :accounts, :conditions => { :active => 1 }
end
#models/account.rb
class Account < ActiveRecord::Base
belongs_to :user
def public_accounts
Account.all :conditions => { public => true }
end
end
Now imagine I want to concatenate User(:id).accounts with Account.public_accounts to show a list of all accounts available to a user.
So you'd think I'd be able to update the User model to look like this.
#models/user.rb
class User < ActiveRecord::Base
has_many :accounts, :conditions => { :active => 1 }
def all_accounts
self.accounts + Account.public
end
end
However, now I won't be able to use the all() method since it's no longer of that type of object.
In the controller I'd like to do this...
#controllers/accounts_controller.rb
def search_all
User.find(params[:user_id]).all_accounts.all(
:offset => params[:offset],
:limit => params[:limit]
)
end
Thoughts?
Update #1:
Scope's won't work for my scenario. I simplified my scenario to try and get my point across. As stated I need a way to combine two active record results and retain the ability to further filter them in my controller.
So the question is, "Why?" The reason is, I am trying to combine two sets of records to form a complete collection and one of the collections is not associated with the user at all.
I have refactored the above scenario to try and show a more precise example without getting overly complicated.
This might be a good scenario to use scopes.
You can define active and inactive scopes in the Account model and then use the following:
User.accounts
User.accounts.active
User.accounts.inactive
You can even chain scopes together, so you could do something like:
User.accounts.active.paid_up
jklina's answer is correct, it's best to use scopes in this situation. Scopes provide a sugary syntax and are more readable. I'll elaborate on the setup:
class User < AR::Base
has_many :accounts
end
class Account < AR::Base
belongs_to :user
scope :active, where(:active => true)
scope :inactive, where(:active => false)
end
You would then access the account scopes as jklina showed: User.find(1).accounts.active, etc.
Accessing all of a user's accounts like: User.find(1).accounts.
UPDATE:
I fixed some mistakes and added more below.
Based on the updates to your question, I think you need to make the public method a method on the class:
class Accounts < AR::Base
...
# This is essentially a scope anyways
def self.public
where(:public => true)
end
end
class User < AR::Base
...
# This should return all the users accounts
# and any public accounts
def all_accounts
Account.where("user_id = ? OR public is true", self.id)
end
end
Lets look at the return values in the chain:
User.find(params[:user_id]) # returns an instance of User class
User.find(params[:user_id]).all_accounts # returns an array
The Array class doesn't have an instance method called all that's why you are seeing this error. This is not a bug.
Why don't you try this:
class User
has_many :accounts, :conditions => { :active => 1 }
has_many :all_accounts :conditions => ["(active = ? OR public = ?)",
true, true]
end
Now you can:
User.find(params[:user_id]).all_accounts.all(:limit => 10, :offset => 2)
You're try to access two distinct tables and apply LIMIT/OFFSET to them as a combined union. That aint gonna happen unless you logically combine them at the SQL layer, not at the ActiveRecord layer.
Sounds like writing out the SQL, maybe using a UNION and then using find_by_sql might be your best best.
I have the following models:
class User < ActiveRecord::Base
has_many :survey_takings
end
class SurveyTaking < ActiveRecord::Base
belongs_to :survey
def self.surveys_taken # must return surveys, not survey_takings
where(:state => 'completed').map(&:survey)
end
def self.last_survey_taken
surveys_taken.maximum(:position) # that's Survey#position
end
end
The goal is to be able to call #user.survey_takings.last_survey_taken from a controller. (That's contrived, but go with it; the general goal is to be able to call class methods on #user.survey_takings that can use relations on the associated surveys.)
In its current form, this code won't work; surveys_taken collapses the ActiveRelation into an array when I call .map(&:survey). Is there some way to instead return a relation for all the joined surveys? I can't just do this:
def self.surveys_taken
Survey.join(:survey_takings).where("survey_takings.state = 'completed'")
end
because #user.survey_takings.surveys_taken would join all the completed survey_takings, not just the completed survey_takings for #user.
I guess what I want is the equivalent of
class User < ActiveRecord::Base
has_many :survey_takings
has_many :surveys_taken, :through => :survey_takings, :source => :surveys
end
but I can't access that surveys_taken association from SurveyTaking.last_survey_taken.
If I'm understanding correctly you want to find completed surveys by a certain user? If so you can do:
Survey.join(:survey_takings).where("survey_takings.state = 'completed'", :user => #user)
Also it looks like instead of:
def self.surveys_taken
where(:state => 'completed').map(&:survey)
end
You may want to use scopes:
scope :surveys_taken, where(:state => 'completed')
I think what I'm looking for is this:
class SurveyTaking < ActiveRecord::Base
def self.surveys_taken
Survey.joins(:survey_takings).where("survey_takings.state = 'completed'").merge(self.scoped)
end
end
This way, SurveyTaking.surveys_taken returns surveys taken by anyone, but #user.survey_takings.surveys_taken returns surveys taken by #user. The key is merge(self.scoped).
Waiting for further comments before I accept..