Method to Pull All Child Objects is Updating Child Objects - ruby-on-rails

I have a Project model, that can have itself as sub-projects defined as Project.projects.
class Project < ApplicationRecord
belongs_to :parent_project, class_name: 'Project', optional: true
has_many :projects, foreign_key: :parent_project_id, class_name: 'Project', dependent: :destroy
has_many :goals
end
Each Project has_many Goals.
I am trying to write a method in the Project model that will enable me to gather all of the goals of this project and it's children projects (recursively) for all sub-projects.
def descendant_projects
self.projects | self.projects.map(&:descendant_projects).flatten
end
def goals_including_descendants
all_goals = self.goals
descendant_projects.each do |project|
all_goals.concat(project.goals)
end
all_goals
end
When I call project.goals_including_descendants, the project_id for the children's projects are getting updated in the database to be direct goals of the original parent project. What part of this code would be causing rails to trigger a database update? I can see it in my console as:
Goal Load (0.0ms) SELECT "goals".* FROM "goals" WHERE "goals"."project_id" = $1 [["project_id", 49]]
(0.0ms) BEGIN
SQL (0.2ms) UPDATE "goals" SET "project_id" = $1, "updated_at" = $2 WHERE "goals"."id" = $3 [["project_id", 1], ["updated_at", "2020-05-14 20:47:47.761861"], ["id", 19]]
I am totally stumped as to why this is happening. Thanks for any insight.

It happens because concat adds the items to the self.goals AR Relation object and updates it immediately.
You could get around this by casting it to an array.
all_goals = self.goals.to_a
descendant_projects.each do |project|
all_goals << project.goals.to_a
end
all_goals.flatten
which will return an array in the end, which may or may not be what you're looking for.
Another way would be to get all the ids. This will return a Relation/Enumerable in the end.
all_goals_ids = self.goals.ids
descendant_projects.each do |project|
all_goals_ids << project.goals.ids
end
Goal.where(id: all_goals_ids)

Related

Querying a join table?

I am trying to do two things:
query an attribute from an inner join table in Rails' Console.
query and displaying the attribute in a view.
These are my Models:
retreat.rb:
class Retreat < ApplicationRecord
belongs_to :user
belongs_to :account
validates :name, presence: true
has_many :retreats_teams
has_many :teams, through: :retreats_teams
accepts_nested_attributes_for :retreats_teams
end
retreats_team.rb:
class RetreatsTeam < ApplicationRecord
belongs_to :team
belongs_to :retreat
end
team.rb:
class Team < ApplicationRecord
belongs_to :account
has_many :team_members
has_many :users, through: :team_members
accepts_nested_attributes_for :team_members
has_many :retreats
has_many :retreats, through: :retreats_teams
end
In Rails' console, if I type:
Retreat.last.teams
I get the output:
irb(main):008:0> Retreat.last.teams
Retreat Load (0.9ms) SELECT "retreats".* FROM "retreats" ORDER BY "retreats"."id" DESC LIMIT $1 [["LIMIT", 1]]
Team Load (0.9ms) SELECT "teams".* FROM "teams" INNER JOIN "retreats_teams" ON "teams"."id" = "retreats_teams"."team_id" WHERE "retreats_teams"."retreat_id" = $1 LIMIT $2 [["retreat_id", 38], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Team id: 56, name: "My house", account_id: 2, created_at: "2020-02-10 15:57:25", updated_at: "2020-02-10 15:57:25">]>
irb(main):009:0>
How do I retrieve the team name: "My house"?
Also, there might be many teams that display here, too.
#teams returns a collection of team objects. The simplest solution is to call first on the teams to get the first team in the collection:
Retreat.last.teams.first.name
=> "My house"
But if you want all the names in teams you might use pluck. This will allow you to do this:
retreat = Retreat.last
foo = Team.create(name: 'Foo')
bar = Team.create(name: 'Bar')
retreat.teams << foo
retreat.teams << bar
retreat.teams.pluck(:name).to_sentence
=> "My house, Foo, and Bar"
A word on naming
The naming convention for join models is SingularSingular. The table should be named singular_plural. has_and_belongs_to_many is the only part of Rails that actually uses the oddball plural_plural naming scheme.
RetreatsTeam # bad
RetreatTeam # better
Even better though is to actually give your join tables meaningful names instead of just placeholder names.
1) querying an attribute from an inner join table in Rails Console.
Since the association between Retreat and RetreatsTeams in one to many you can actually only fetch aggregates. Otherwise which attribute should it fetch, from the first row, the last row or all the rows?
So for example you can do:
Retreat.joins(:retreats_teams)
.select('retreats.*', 'COUNT(retreats_teams.*) AS retreats_teams_count')
If you are storing more data on the join table that you want to display you want to iterate through the join table:
#retreat = Retreat.eager_load(retreats_teams: :teams).first
#retreat.retreats_teams.each do |rt|
puts rt.foo
puts rt.team.name
end
2) querying and displaying the attribute in a view.
In Rails you're usually just fetching records in the controller and then iterating through them in the view:
class ResortsController < ApplicationController
def show
#resort = Resort.includes(:teams).find(params[:id])
end
end
# app/views/resorts/show.html.erb
<h1><%= #resort.name %></h1>
<h2>Teams</h2>
<% if #resort.teams.any? %>
<ul>
<% #resort.teams.each do |team| %>
<li><%= team.name %></li>
<% end %>
</ul>
<% else %>
<p>This resort has no teams</p>
<% end %>

Workaround for AssociationTypeMismatch?

I'm in the middle of a refactoring, where I have written new models for new incoming data that will be behind a feature flag, while simultaneously keeping old models active for incoming data for people who don't yet have the feature flag. The old and new models both interact with the same table in the database.
i.e.
class Poem < ApplicationRecord
belongs_to: :author
belongs_to: :anthology, optional: true
end
class Anthology < ApplicationRecord
belongs_to: :author
has_many: :poems
end
class Author < ApplicationRecord
has_many: :anthologies
has_many: :poems
end
class NewPoem < ApplicationRecord
self.table_name = 'poems'
belongs_to: :author, class_name: 'NewAuthor'
belongs_to: :anthology, class_name: 'NewAnthology', optional: true
end
class NewAnthology < ApplicationRecord
self.table_name = 'anthologies'
belongs_to: :author, class_name: 'NewAuthor'
has_many: :poems, class_name: 'NewPoem'
end
class NewAuthor < ApplicationRecord
self.table_name = 'authors'
has_many: :anthologies, class_name: 'NewAnthology'
has_many: :poems, class_name: 'NewPoem'
end
When I have new books being created, I want to assign the new book to the author
anthology = Anthology.find(1)
#poem = NewPoem.new
#poem.author = anthology.author
Which gives me an error
ActiveRecord::AssociationTypeMismatch (NewAuthor(#70008442274840) expected, got Author(#47031421032620)):
Is there any way to get around this? or will I not be able to associate the models to the old models without a data migration?
You would need to change the first line that fetches the Anthology record. Currently, it fetches a record of the Anthology model as anthology = Anthology.find(1). However, this model refers to the old Author model.
In the next line, #poem = NewPoem.new instantiates an object of the NewPoem model, and the next line tries to assign an author to it. However, #poem.author would be an object of the class NewAuthor, but it is being given a value as #poem.author = anthology.author, where anthology.author still refers to the old Author table.
Changing the first line to anthology = NewAnthology.find(1) would fix the issue. It would fetch the same record as Anthology.find(1) since they query on the same table.
Overall code then becomes:
anthology = NewAnthology.find(1)
#poem = NewPoem.new
#poem.author = anthology.author
It looks like the new models are just supersets of the old ones. You could define NewPoem (and the rest of new models) as:
class NewPoem < Poem
validates :new_colums
def new_cool_stuff
end
end
That way you can avoid to redefine associations.
ActiveRecord actually has a built in Single Table Inheritance mechanism that you can use here.
Start by adding a column named type to the table:
class AddTypeToPoems < ActiveRecord::Migration[5.2]
def change
add_column :poems, :type, :string
add_index :poems, :type
end
end
Then set the classes to inherit:
class Poem < ApplicationRecord
end
class NewPoem < Poem
# gets its table name from the parent class
end
ActiveRecord automatically sets the type column and even uses it when retrieving records:
irb(main):001:0> NewPoem.create
(0.3ms) BEGIN
NewPoem Create (1.3ms) INSERT INTO "poems" ("created_at", "updated_at", "type") VALUES ($1, $2, $3) RETURNING "id" [["created_at", "2019-05-08 19:17:45.086947"], ["updated_at", "2019-05-08 19:17:45.086947"], ["type", "NewPoem"]]
(0.7ms) COMMIT
=> #<NewPoem id: 1, title: nil, created_at: "2019-05-08 19:17:45", updated_at: "2019-05-08 19:17:45", type: "NewPoem">
irb(main):002:0> Poem.first
Poem Load (0.7ms) SELECT "poems".* FROM "poems" ORDER BY "poems"."id" ASC LIMIT $1 [["LIMIT", 1]]
=> #<NewPoem id: 1, title: nil, created_at: "2019-05-08 19:17:45", updated_at: "2019-05-08 19:17:45", type: "NewPoem">
irb(main):003:0>
So even if we use Poem.first it instanciates a NewPoem instance from the result. If you use the finders on the subclasses rails will automatically scope it:
irb(main):003:0> NewPoem.all
NewPoem Load (1.8ms) SELECT "poems".* FROM "poems" WHERE "poems"."type" IN ('NewPoem') LIMIT $1 [["LIMIT", 11]]
If you only want the "old" type you need to use:
Poem.where(type: [nil, 'Poem'])

How would a 'commentable' polymorphic association work on a 'user' model itself?

I'm learning rails and trying out polymorphic association. I have listed below a couple of simple models for illustration. Model associations seems to works fine as expected. But what if a user (commenter) would like to leave a comment for a another user? I can't seems to get it to work with these configuration. How do I go about doing so?
class User < ApplicationRecord
# username, email, password_digest
has_many :comments, as: :commentable, dependent: :destroy
end
class Project < ApplicationRecord
# title, completed, user_id
has_many :comments, as: :commentable, dependent: :destroy
end
class Comment < ApplicationRecord
# commenter_id, commentable_type, commentable_id, body
belongs_to :commentable, polymorphic: true
end
in console... setup
user1 = User.frist
user2 = User.last
project = Project.first
pro_comment = project.comments.new(body: 'some text')
pro_comment.commenter_id = user1.id
pro_comment.save
user_comment = user2.comments.new(body: 'some text')
user_comment.commenter_id = user1.id
user_comment.save
expected and actual results
Comment.all => successfully lists pro_comment & user_comment
But...
Comment.find_by(commenter_id: 1) => only listed the pro_comment
(what am I doing wrong?)
Also..
user1.comments => returned an empty object... was expecting 2 objects,
as you can see below it's not referencing 'commenter_id' ....
result...
comment Load (0.5ms) SELECT "comments".* FROM "comments" WHERE
"comments"."commentable_id" = $1 AND "comments"."commentable_type" = $2
LIMIT $3 [["commentable_id", 1], ["commentable_type", "User"],
["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy []>
I also tried ...
user1.comments.where(commenter_id: 1) >> which returned...
comment Load (0.4ms) SELECT "comments".* FROM "comments" WHERE
"comments"."commentable_id" = $1 AND "comments"."commentable_type" = $2
AND "comments"."commenter_id" = $3 LIMIT $4 [["commentable_id", 1],
["commentable_type", "User"], ["commenter_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::AssociationRelation []>
Not sure what I'm doing wrong. Could someone please point me in the right direction.
I thank you for your time.
find_by returns only one record, try Comment.where(commenter_id: 1) instead.
For user1.comments being empty, you are mixing the relationships. You should have 2 relationships: comment belongs to a commentable object (a project or a user) and comments also belongs to a commenter (the user you set as commenter_id).
It makes sense for user1.comments to be empty since the user is the commenter on both comments, it's not the commentable. user2.comments shouldn't be empty, same for project.comments
Try something like this:
class User < ApplicationRecord
has_many :comments_done, class_name: 'Comment', inverse_of: :commenter
has_many :comments, as: :commentable, dependent: :destroy
end
class Comment < ApplicationRecord
belongs_to :commenter, class_name: 'User'
belongs_to :commentable, polymorphic: true
end
(check the guide, I may be missing some config option https://guides.rubyonrails.org/v5.2/association_basics.html#has-many-association-reference)
Now you can use user1.comments_done and user1.comments for comments done by the user and done at the user.

Rails pre-loading association with multiple foreign keys

Let's say I have the following two models, joined separately by the following two joins models:
class Game
has_many :game_plays
has_many :game_views
end
class Person
has_many :game_plays
has_many :game_views
end
# Games that a Person has played
class GamePlay
belongs_to :game
belongs_to :person
end
# Games that a Person has viewed
class GameView
belongs_to :game
belongs_to :person
end
Given a specific GamePlay, I want to get the GameView for the same Person-Game combo, e.g:
game_play = GamePlay.first
game_view = GameView.find_by(game_id: game_play.game_id, person_id: game_play.person_id)
I also need to eager load that association.
I'd love to create an association between GamePlay and GameView, but nothing I've tried has worked so far.
Attempt 1
class GamePlay
belongs_to :game
belongs_to :person
has_one :game_view, -> (gp) { where(game_id: gp.game_id) }, foreign_key: :person_id, primary_key: :person_id
end
This works, but I can't include this:
GamePlay.includes(:game_view).first
# => ArgumentError: The association scope 'game_view' is instance dependent (the scope block takes an argument). Preloading instance dependent scopes is not supported.
Attempt 2
class GamePlay
belongs_to :game
belongs_to :person
def game_view
GameView.find_by(game_id: game_id, person_id: person_id)
end
end
This obviously works, but I can't include this because it isn't defined as an association.
Any thoughts? Thanks!
Rails 5.0.0postgres 9.6.2
How about:
class GamePlay < ApplicationRecord
belongs_to :game
belongs_to :person
has_one :game_view, through: :person, source: :game_views
end
irb(main):002:0> GamePlay.includes(:game_view).find(2)
GamePlay Load (0.2ms) SELECT "game_plays".* FROM "game_plays" WHERE "game_plays"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
Person Load (0.2ms) SELECT "people".* FROM "people" WHERE "people"."id" = 1
GameView Load (0.2ms) SELECT "game_views".* FROM "game_views" WHERE "game_views"."person_id" = 1
=> #<GamePlay id: 2, game_id: 1, person_id: 1>
irb(main):008:0> GamePlay.find(2).game_view
GamePlay Load (0.1ms) SELECT "game_plays".* FROM "game_plays" WHERE "game_plays"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
GameView Load (0.2ms) SELECT "game_views".* FROM "game_views" INNER JOIN "people" ON "game_views"."person_id" = "people"."id" WHERE "people"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<GameView id: 2, game_id: 1, person_id: 1>
I had the same challenge, I solved this with a custom version of Rails preloader https://github.com/2rba/smart_preloader
The association is exactly as you described:
class GamePlay
belongs_to :game
belongs_to :person
has_one :game_view, -> (gp) { where(game_id: gp.game_id) }, foreign_key: :person_id, primary_key: :person_id
end
and then explicitly call preloader as:
game_plays =GamePlay.all
ActiveRecord::SmartPreloader.(game_plays, ActiveRecord::CompositeKey.new(:game_view, [:game_id, :person_id])
that is pretty much the same as Rails default GamePlay.preloads(:game_view) behaviour which under the hood calls ActiveRecord::Associations::Preloader. The only difference preloader called explicitly, and preloader is slightly modified to support multikey and polymorphic associations.

Rails 4 - chaining model associations to access associated methods

I have models for User, Profile and Organisation Request. The associations are:
User
has_one :profile, dependent: :destroy
has_one :organisation_request, through: :profile
accepts_nested_attributes_for :organisation_request
Profile
belongs_to :user
belongs_to :organisation
Organisation Request
belongs_to :profile
# belongs_to :user#, through: :profile
belongs_to :organisation
In my user model, I have a method called full_name (which I use to format the presentation of a user's name.
I'm trying to access that full_name method in my organisation_requests model.
I'm trying to do that by writing the following method in my organisation requests model:
def related_user_name
self.profile.user.full_name
end
When I try to use this in my organisation requests index, like this:
<%= link_to orgReq.related_user_name, organisation_request.profile_path(organisation_request.profile.id) %>
I get an error that says:
undefined method `user' for nil:NilClass
When I try to use this idea in the rails console, with:
o = OrganisationRequest.last
OrganisationRequest Load (0.4ms) SELECT "organisation_requests".* FROM "organisation_requests" ORDER BY "organisation_requests"."id" DESC LIMIT 1
=> #<OrganisationRequest id: 2, profile_id: 1, organisation_id: 1, created_at: "2016-08-01 22:48:52", updated_at: "2016-08-01 22:48:52">
2.3.0p0 :016 > o.profile.user.formal_name
Profile Load (0.5ms) SELECT "profiles".* FROM "profiles" WHERE "profiles"."id" = $1 LIMIT 1 [["id", 1]]
User Load (0.5ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1 [["id", 1]]
=> " John Test"
The concept seems to work in the console?
Can anyone see where I've gone wrong?
Don't chain methods, it's a bad practice, it violates the Law of Demeter. The best choice is to use the delegate. So instead of:
def related_user_name
self.profile.user.full_name
end
You can have:
class OrganisationRequest
belongs_to :profile
has_one :user, through: :profile
delegate :full_name, to: :user, allow_nil: true, prefix: true
end
Then you can just call organisation_request.user_full_name and it will go through profile > user and call full_name (and you won't get undefined since the allow_nil: true will "cover" it)
More info about delegate here.
have you checked all of your organisation requests have profile? may be this is not best practice, try to use profile.try(:user).try(:full_name)

Resources