Is there a way to preload an arbitrary number of parent associations in Rails? - mysql

TL;DR: I have a model that belongs_to :group, where group is another instance of the same model. That "parent" group can also have a parent, and so on up the chain. Is there a way to includes this structure as far up as it goes?
I have a Location model, which looks like this (abridged version):
create_table "locations", force: :cascade do |t|
t.string "name"
t.decimal "lat", precision: 20, scale: 15
t.decimal "long", precision: 20, scale: 15
t.bigint "group_id"
t.string "type"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["group_id"], name: "index_locations_on_group_id"
end
class Location < ApplicationRecord
belongs_to :group, class_name: 'Location', required: false
has_many :locations, foreign_key: 'group_id', dependent: :destroy
end
In other words, it can optionally belong to a "parent" instance of itself, referenced as group.
That parent instance can also belong to a parent instance another level up, as can its parent, etc etc. Elephants, all the way down.
What I'd like to do is string the names of a Location and all its parent instances together, so I end up with something like "Top-level group > Mid-level group > Lowest group > Location". This is fine, and I've implemented that in the model already:
def parent_chain
Enumerator.new do |enum|
parent_group = group
while parent_group != nil
enum.yield parent_group
parent_group = parent_group.group
end
end
end
def name_chain
(parent_chain.map(&:name).reverse + [name]).join(" \u00BB ")
end
The only problem with this, however, is that it will query individually for each parent instance as it gets there (the N+1 problem). Once I'm including several Locations in a single page, this is a lot of queries slowing the load down. I'd like to preload (via includes) this structure as I would for a normal belongs_to association, but I don't know if there's a way to include an arbitrary number of parents like this.
Is there? How do I do it?

Using includes? No. Recursive preloading could be achieved this way though:
Solution #1: True recursion
class Location
belongs_to :group
# Impure method that preloads the :group association on an array of group instances.
def self.preload_group(records)
preloader = ActiveRecord::Associations::Preloader.new
preloader.preload(records, :group)
end
# Impure method that recursively preloads the :group association
# until there are no more parents.
# Will trigger an infinite loop if the hierarchy has cycles.
def self.deep_preload_group(records)
return if records.empty?
preload_group(records)
deep_preload_group(records.select(&:group).map(&:group))
end
end
locations = Location.all
Location.deep_preload_group(locations)
The number of queries will be the depth of the group hierarchy.
Solution #2: Accepting a hierarchy depth limit
class Location
# depth needs to be greather than 1
def self.deep_preload_group(records, depth=10)
to_preload = :group
(depth - 1).times { to_preload = {group: to_preload} }
preloader = ActiveRecord::Associations::Preloader.new
preloader.preload(records, to_preload)
end
end
The number of queries will be the minimum of depth and the actual depth of the hierarchy

Related

Why isn't Ruby on Rails loading my associated objects with .includes()?

My Problem
I'm attempting to retrieve data through a foreign key association in my Ruby on Rails application. The data from the primary table is loaded correctly, but the associated objects are not being loaded and are always nil.
Background Info (Migrations, Database tables, and Model classes)
I'm currently working with two tables:
eval_forms
user_details
The tables are created through Rails migrations.
The user_details table is created through a single migration:
class CreateUserDetails < ActiveRecord::Migration[5.2]
def change
create_table :user_details do |t|
t.string :eduPersonPrincipalName, unique: true
t.string :DisplayName, default: 'NULL'
t.string :Email, default: 'NULL'
t.string :Role, default: 'Student'
t.boolean :hasAppointment, default: '0'
t.timestamps
end
end
def self.down
drop_table :user_details
end
end
and the eval_forms table has had a few migrations to create and update it:
class CreateEvalForms < ActiveRecord::Migration[6.1]
def change
create_table :eval_forms do |t|
t.belongs_to :form_builder, foreign_key: 'form_builder_id'
t.belongs_to :course, foreign_key: 'course_id'
t.string :Description
t.datetime :OpenDate
t.datetime :CloseDate
t.timestamps
end
end
end
class UpdateEvalForms < ActiveRecord::Migration[6.1]
def change
add_column :eval_forms, "Author_user_details_id", :bigint, null: false
add_foreign_key :eval_forms, :user_details, column: "Author_user_details_id"
add_column :eval_forms, "Year", :integer
add_column :eval_forms, "Semester", :string
add_column :eval_forms, "IsArchived", :boolean
end
end
I know that the foreign key is set up correctly as it is listed correctly in MySQL. Here's a reference from MySQL of the 2 tables and their relation:
Additionally, I've set up the model classes in my Rails app.
eval_form:
class EvalForm < ApplicationRecord
has_many :eval_forms_roles
has_many :roles, through: :eval_forms_roles
has_many :eval_forms_courses
has_many :courses, through: :eval_forms_courses
has_many :eval_responses
has_many :eval_reminders
belongs_to :user_detail
validates :formName, presence: true
validates :formData, presence: true
end
user_detail:
class UserDetail < ApplicationRecord
has_one :la_detail
has_many :eval_responses
has_many :eval_forms
end
So What's Wrong?
Lastly, here is the code to retrieve the objects from the database and the section where I'm getting my error.
My controller action:
def index
# list *all* existing evaluation forms, with options to filter by OpenDate, CloseDate, etc (todo)
#EvalForms = EvalForm.includes(:user_detail)
end
My view:
<td><%= ef.user_detail.DisplayName %></td>
My error:
NoMethodError in Evaluations::EvalForms#index
undefined method `DisplayName' for nil:NilClass
Extracted Source location: <td><%= ef.user_detail.DisplayName %></td>
Restating the problem
In conclusion, I'm really confused as to why the associated user_detail objects are not being retrieved despite my .includes() statement in the controller action. I'm pretty new to Ruby as well as Rails, but there are other sections in the application that look similar to this and work correctly so I don't see what my issue is.
I would start by using conventional naming which in Rails means snake_case everywhere:
class CreateUserDetails < ActiveRecord::Migration[5.2]
def change
create_table :user_details do |t|
t.string :edu_person_principal_name, unique: true
t.string :display_name
t.string :email
t.string :role, default: 'Student'
t.boolean :has_appointment, default: false # let the driver handle conversion
t.timestamps
end
end
end
class UpdateEvalForms < ActiveRecord::Migration[6.1]
def change
change_table :eval_forms do |t|
t.belongs_to :author_user_details, foreign_key: { to_table: :user_details }
t.integer :year # consider using `YEAR(4)` instead
t.string :semester
t.boolean :is_archived, default: false
end
end
end
If you continue using a strange mix of camelCase and PascalCase you will need to explicitly configure all your associations and you will lose all the advantages of convention over configuration. I would not recommend this at all unless you're stuck with a legacy database as its a surefire recipe for developer confusion and bugs.
You will also get a missing constant error if you call the PascalCase methods without an explicit recipient (self):
class EvalForm < ApplicationRecord
def short_description
# uninitialized constant Description (NameError)
Description.truncate(27, separator: ' ')
end
end
While you can fix this with self.Description.truncate(27, separator: ' ') its still very smelly.
In this case if you want to call the column author_user_details_id instead of user_details_id which is derived from the name you need to configure the assocation to use the non-conventional name:
class EvalForm < ApplicationRecord
belongs_to :user_detail, foreign_key: :author_user_details_id
end
class UserDetail < ApplicationRecord
has_many :eval_forms, foreign_key: :author_user_details_id
end
If the rest of your schema looks like this you'll have to do this across the board.

Trying to relate tables in MySQL and Rails

I'm having problems relating some tables, I have the client table with the fields ("name, age and gender") and another table called personal_documents with the fields "cpf, rg, etc ...), I tried the relationship of personal_documents belongs_to client but when i search for client only the fields of client ("name, age and gender) and "personal_documents_id" appear, the fields for personal documents ("cpf, rg, etc...) should also appear too, thanks for the help!
Code:
In client model:
has_one :personal_documents
in personal_documents model:
belongs_to :client
rails generate model Client
inside migration file you create as follow
class CreateClients < ActiveRecord::Migration[6.0]
def change
create_table :clients do |t|
t.string :user_kind
# your other field here
t.timestamps
end
end
end
rails generate model PersonalDocument
inside migration file you create as follow
class CreatePersonalDocuments < ActiveRecord::Migration[6.0]
def change
create_table :personal_documents do |t|
# this is the one that relate personal document
# to client
t.references :client, index: true
t.string :rg_front
# other field
t.timestamps
end
end
end
inside model you can declare as follow
class Client < ApplicationRecord
# please note personal_document in singular
has_one :personal_document, dependent: :destroy
accepts_nested_attributes_for :personal_document, allow_destroy: :true
# now you can do some like above for disponibility, personal_document_legal, bank_information
end
class PersonalDocument < ApplicationRecord
belongs_to :client
end
inside your controller you declare as follow
class ClientsController < ApplicationController
def barang_params
params.require(:client).permit(
:user_kind,
personal_document_attributes: [
:id,
:rg_front,
:rg_back,
:cpf,
:cnh_front,
:cnh_back,
:bank_card_front,
:address_proof,
:profile_picture
]
# this from your other question, and I think it's already correct
)
end
end
To access personal_documents of client
Client.find(1).personal_documents.cpf
To access client of personal_documents
PersonalDocument.find(id).client.name
both
document = PersonalDocument.find(id)
client = document.client
or
client = Client.find(1)
document = client.personal_documents
document.cpf
client.name
additionaly change :has_one to singular personal_document

How to build M to M relationships between two data model

Condition:
Tutor can follow or dis-follow student
Student can follow or dis-follow tutor
Tutor and Student are two different data models.
I build a middle data model calledapplication connecting both model
Application model:
class CreateApplications < ActiveRecord::Migration
def change
create_table :applications do |t|
t.belongs_to :tutor, index: true
t.belongs_to :student, index: true
t.timestamps null: false
end
end
end
application.rb
class Application < ActiveRecord::Base
belongs_to :tutor
belongs_to :student
end
student.rb
has_many :applications, :dependent => :destroy
has_many :tutors, through: :applications
tutor.rb
has_many :applications, :dependent => :destroy
has_many :students, through: :applications
schema.rb
ActiveRecord::Schema.define(version: 20170421093747) do
create_table "applications", force: :cascade do |t|
t.integer "tutor_id"
t.integer "student_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index "applications", ["student_id"], name: "index_applications_on_student_id"
add_index "applications", ["tutor_id"], name: "index_applications_on_tutor_id".......
show.html.erb
# -->show who is the follower & person who followed & How many of them
# Error :undefined method `student' for nil:NilClass
<%= #application.student.count %> #undefined method `student' for nil:NilClass
<%= #application.tutor.count % >#undefined method `student' for nil:NilClass
<%= #application.student%> #undefined method `student' for nil:NilClass
In your controller, you need to set the #application variable.
def show
#application = Application.find(params[:id])
end
I would strongly advise against naming this class Application. The word "Application" has special meaning in Rails and you will almost certainly run into conflicts. Same goes for ApplicationController.
When model has many relationship with other model you want to use plural name, i.e. tutor.students or student.tutors
UPD: Also, you didn't pass variable into view properly, check your controller.
UPD1: Also, it's meaningless to use count on application record (btw you want to rename it to something like Courses, Classes or just StudentTutor because Application.rb is the model all your models inherit from by default, same goes to ApplicationController). It always has one student and one tutor (it belongs to both models). If you want to count related records you want to find student (if you want to count his tutors) or tutor (for vise-versa)

Rails active record object save action fails because of a malformed query

I have a problem in Rails in the part of my app that handles landing pages for registration emails. When a user is invited to the app, an Invitation active record instance is created, and user is sent an email with a link containing the id of the invitation (a random string token).
Then, when the link is clicked, I store that token in session, and at one point in the service layer, I update it's 'status' attribute to 'clicked', something like this:
#invitation = Invitation.find_by_id(session[:registration][:invitation_token])
unless #invitation.blank?
session[:registration][:account_details]['referer'] = #invitation.promoter.username
unless #invitation.status == APP_CONFIG['invitation_status']['clicked']
#invitation.status = APP_CONFIG['invitation_status']['clicked']
#invitation.save
end
end
Upon executing the #invitation.save line, I get an active record error:
!! #<ActiveRecord::StatementInvalid: Mysql2::Error: Unknown column 'invitations.' in 'where clause': UPDATE `invitations` SET `status` = 'clicked', `updated_at` = '2015-11-11 11:07:24' WHERE `invitations`.`` = 'fd05ee5a-e790-48cc-9e7e-d30d3e88919b'>
The Invitation's id column name seems to be ommited from the query for some reason, and I can't figure out why. Not sure what is wrong or what to do.
Invitation migration:
class CreateInvitations < ActiveRecord::Migration
def change
create_table :invitations, id: false do |t|
t.string :id, limit: 36, :auto_increment => false
t.string :promoter_id
t.string :email
t.string :status
t.timestamps
end
end
end
Invitation model:
class Invitation < ActiveRecord::Base
#region Callbacks
before_create :set_uid
#endregion
belongs_to :promoter, class_name: 'User', foreign_key: 'promoter_id'
private
def set_uid
self.id = SecureRandom.uuid unless self.id
end
end
You should mark the id column as the primary_key:
create_table :invitations, id: false do |t|
t.string :id, limit: 36, primary_key: true, auto_increment: false
...
I think that in your case the best way to solve this problem is to drop the table and create it again with correct migration. But if there is data that you don't want to loose, you can also try to do it like this:
class MarkIdAsPrimaryKey < ActiveRecord::Migration
def change
change_column :invitations, :id, :string, limit: 36, primary_key: true, auto_increment: false
end
end
Keep in mind that this migration is not reversible, it remains under your responsibility if you will use it ) Good luck !

How to Show Table Entry Name Instead of id on Show or Index

Ok. Asked this question yesterday and since started a whole new rails app to see if starting from scratch helps.
Here's how this app works. A user will create a new countertop and enter their zip code, the countertop size and the type of countertop. Possible countertop types are stored in a model called "Countertype". Users select the countertype through a collection_select method, which lists all the entries in the Countertype table. The responses to this Countertop form are saved in a Countertop table, which has a "countertop_id" integer column.
When the user lands on the Show and then the Index page, I'd like the name of the countertype to be visible instead of the integer.
How do I do this? It's killing me.
Here's my schema:
create_table "countertops", force: :cascade do |t|
t.string "counterzip"
t.string "countersize"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "countertype_id"
end
create_table "countertypes", force: :cascade do |t|
t.string "name"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
Here's my index and show
def index
#countertops = Countertop.all
#countertops = Countertop.includes(:countertype).all
end
def show
#countertops = Countertop.all
#countertops = Countertop.includes(:countertype).all
end
Countertop.rb:
class Countertop < ActiveRecord::Base
has_one :countertype
end
Countertype.rb
class Countertype < ActiveRecord::Base
belongs_to :countertop
end
Show:
<p>
<strong>Counter Type:</strong>
<%= #countertop.countertype.name %>
</p>
Index:
<% #countertops.each do |countertop| %>
<tr>
<td><%= countertop.counterzip %></td>
<td><%= countertop.countertype.name %>
Here's a readout from my console for both tables.
Countertop.last
Countertop Load (0.2ms) SELECT "countertops".* FROM "countertops" ORDER BY "countertops"."id" DESC LIMIT 1
=> #<Countertop id: 1, counterzip: "19111", countersize: "100", created_at: "2015-10-01 20:44:29", updated_at: "2015-10-01 20:44:29", Countertype_Id: 1>
2.2.1 :029 >
Countertype.last
Countertype Load (0.7ms) SELECT "countertypes".* FROM "countertypes" ORDER BY "countertypes"."id" DESC LIMIT 1
=> #<Countertype id: 1, name: "Granite", created_at: "2015-10-01 20:15:12", updated_at: "2015-10-01 20:15:12">
Heres's the error message:
SQLite3::SQLException: no such column: countertypes.countertop_id: SELECT "countertypes".* FROM "countertypes" WHERE "countertypes"."countertop_id" = ? LIMIT 1
Changing the show to <%= #countertops.countertype_id %> displays a "1".
What do I need to fix to have it display "Granite" instead of "1" ??
Thanks!!
It looks like you have your associations backwards in the two models. belongs_to should be listed in the model whose table contains the association column (countertype_id in this case). has_one relies on the associated model to have this column.
A couple additional observations:
According to the output from Countertop.last, Countertype_Id has uppercase characters. This might not cause immediate problems with sqlite, but it may be worth changing to avoid any future problems, as Rails will assume column names are always downcased unless explicitly told otherwise.
Your #show controller method has #countertops defined (plural), but the show view uses just #countertop.