ActiveRecord nested associations - ruby-on-rails

I have some models like Service, Payment and Manager
class Service < ApplicationRecord
validates :name, presence: true
has_and_belongs_to_many :tickets
has_many :payments
end
class Payment < ApplicationRecord
belongs_to :manager
belongs_to :service
validates :value, :manager_id, :service_id, presence: true
end
class Manager < ApplicationRecord
validates :name, presence: true
end
Service can have multiple Payments and each Payment has single Manager.
I want to get the whole data using all nested associations as a hash that I could reformat (or map) and sent to the client. I am stuck with query:
Service.includes(payments: :manager).references(:payments, :managers)
as it is using lazy load, and I need to do something like:
services.first.payments.first.manager
to get data, and it is non-optimal.
Is it possible to get all data with all nested associations?
I did calculations like this:
services = Service.includes(payments: :manager)
.references(:payments, :managers)
result = []
services.each do |service|
service.payments.each do |payment|
manager_name = payment[:manager][:name]
value = payment[:value]
service[manager_name] = value
end
result.push(service)
end
and got an error NoMethodError (undefined method '[]' for nil:NilClass):
on manager_name = payment[:manager][:name] line.

You can define an additional has_many through relationship which should give you what you want.
has_many :managers, through: :payments
class Service < ApplicationRecord
validates :name, presence: true
has_and_belongs_to_many :tickets
has_many :payments
has_many :managers, through: :payments
end
class Payment < ApplicationRecord
belongs_to :manager
belongs_to :service
validates :value, :manager_id, :service_id, presence: true
end
class Manager < ApplicationRecord
validates :name, presence: true
end

I did not understand your need %100 but I come up and idea like following. Please review it.
payments = Payment.joins(:service).select(:service_id, :manager_id, :value).
group_by(&:service_id)
managers = Manager.where('id IN(SELECT manager_id FROM payments)').
select(:name,:id)
group_by(&:id).transform_values{ |value| value.first }
result = []
ResultClass = Struct.new(:service, :manager, :payment_value)
Service.all.each do |service|
payment = payments[service.id]
manager = managers[payment.manager_id]
result << ResultClass.new(service, manager, payment.value)
end
With this code, we are actually trying to accomplish rails includes mechanism under the hood. What we did actually here?
We get the payments and converted them to hash that the id is hash key
Then we get the managers those have payments and we converted also these records with manager id as hash key
We created ResultClass with Struct to store the references of objects like service and manager
We start the iteration. Because of the fact that we have payments and members as hash, there is fast look up and there is no time complexity. In overall, this iteration's time complexity is O(n)
If you have any questions please feel free to drop a comment.

After lots of tries I've finally find some misspells I made and archieved behaviour I want using such code
services = Service.eager_load(payments: :manager)
services.reduce([]) do |acc, service|
service_rec = {
id: service[:id],
name: service[:name],
surgery: service[:surgery]
}
service.payments.each do |payment|
manager_name = payment.manager[:name]
value = payment[:value]
service_rec[manager_name] = value
end
acc.push(service_rec)
end
and SQL query it produces
SQL (0.2ms) SELECT "services"."id" AS t0_r0, "services"."code" AS t0_r1, "services"."name" AS t0_r2, "services"."surgery" AS t0_r3, "services"."enabled" AS t0_r4, "services"."created_at" AS t0_r5, "services"."updated_at" AS t0_r6, "payments"."id" AS t1_r0, "payments"."service_id" AS t1_r1, "payments"."manager_id" AS t1_r2, "payments"."value" AS t1_r3, "payments"."created_at" AS t1_r4, "payments"."updated_at" AS t1_r5, "managers"."id" AS t2_r0, "managers"."name" AS t2_r1, "managers"."enabled" AS t2_r2, "managers"."created_at" AS t2_r3, "managers"."updated_at" AS t2_r4 FROM "services" LEFT OUTER JOIN "payments" ON "payments"."service_id" = "services"."id" LEFT OUTER JOIN "managers" ON "managers"."id" = "payments"."manager_id"
It is interesting that using includes instead of eager_load produces three queries instead
Service Load (0.1ms) SELECT "services".* FROM "services"
↳ app/services/payments_service.rb:6
Payment Load (0.1ms) SELECT "payments".* FROM "payments" WHERE "payments"."service_id" IN (?, ?, ?, ?, ?, ?, ?, ?) [["service_id", 1], ["service_id", 2], ["service_id", 3], ["service_id", 4], ["service_id", 5], ["service_id", 6], ["service_id", 7], ["service_id", 8]]
↳ app/services/payments_service.rb:6
Manager Load (0.1ms) SELECT "managers".* FROM "managers" WHERE "managers"."id" IN (?, ?, ?, ?) [["id", 1], ["id", 2], ["id", 3], ["id", 4]]
Also we can use Service.includes(payments: :manager).references(:payments, :managers) and got the same query as eager_load but it's longer to type))
Thanks everyone for participation! Does anyone have another opinion about eager_load or code optimisation proposals?

Related

Eager load Rails' has_many with two primary_keys and foreign_keys

I have two models
class TimeEntry < ApplicationRecord
belongs_to :contract
end
class Timesheet < ApplicationRecord
belongs_to :contract
has_many :time_entries, primary_key: :contract_id, foreign_key: :contract_id
end
Additionally, both models have a date column.
The problem: A Timesheet is only for a fixed date and by scoping only to contract_id I always get all time_entries of a contract for each Timesheet.
I tried to scope it like this:
has_many :time_entries, ->(sheet) { where(date: sheet.date) }, primary_key: :contract_id, foreign_key: :contract_id
This works, but unforunately it is not eager loadable:
irb(main):019:0> Timesheet.where(id: [1,2,3]).includes(:time_entries).to_a
Timesheet Load (117.9ms) SELECT "timesheets".* FROM "timesheets" WHERE "timesheets"."id" IN ($1, $2, $3) [["id", 1], ["id", 2], ["id", 3]]
TimeEntry Load (0.3ms) SELECT "time_entries".* FROM "time_entries" WHERE "time_entries"."date" = $1 AND "time_entries"."contract_id" = $2 [["date", "2014-11-21"], ["contract_id", 1]]
TimeEntry Load (0.3ms) SELECT "time_entries".* FROM "time_entries" WHERE "time_entries"."date" = $1 AND "time_entries"."contract_id" = $2 [["date", "2014-11-22"], ["contract_id", 1]]
TimeEntry Load (0.3ms) SELECT "time_entries".* FROM "time_entries" WHERE "time_entries"."date" = $1 AND "time_entries"."contract_id" = $2 [["date", "2014-11-23"], ["contract_id", 1]]
Is it possible, to provide Rails with two primary_keys AND foreign_keys? Or how could I make the example above eager loadable to avoid n+1 queries?
You can use a custom SQL query for the association to retrieve the TimeEntry records for a given Timesheet in this way:
class Timesheet < ApplicationRecord
belongs_to :contract
has_many :time_entries, lambda {
select('*')
.from('time_entries')
.where('time_entries.date = timesheets.date')
.where('time_entries.contract_id = timesheets.contract_id')
}, primary_key: :contract_id, foreign_key: :contract_id
end
Then, can use
timesheets = Timesheet.where(id: [1,2,3]).eager_load(:time_entries)
time_entries = timesheets.first.time_entries
Note:- this will only work with while eager loading, not preloading. That's why explicitly using the keyword instead of includes.

Create action in controller is omitting reference field

I have a problem with my rails application when i create a customer for an environment, the action itself if omitting the parameter environment_id in the insert statment.
This is my customer.rb model:
class Customer < ActiveRecord::Base
belongs_to :environment, inverse_of: :customers
has_many :all_services, class_name: 'Service'
has_many :services, inverse_of: :customer
has_paper_trail ignore: %i[created_at updated_at]
end
This is my environment.rb model:
class Environment < ActiveRecord::Base
has_many :all_customers, class_name: 'Customer', dependent: :destroy
has_many :customers, inverse_of: :environment
has_many :all_services, class_name: 'Service', dependent: :destroy
has_many :services, inverse_of: :environment
has_many :all_versions, class_name: 'Version', dependent: :destroy
has_many :versions, inverse_of: :environment
has_many :all_role_permissions, class_name: 'RolePermission', dependent: :destroy
has_many :role_permissions, inverse_of: :environment
has_paper_trail ignore: %i[created_at updated_at]
end
This is the customer_controller.rb create action:
def create
if customer_params.permitted?
render json: Customer.create!(customer_params), status: :ok
else
render json: { message: Api::V1::INVALID_PARAMETERS }, status: :bad_request
end
end
def customer_params
params.require(:customer).permit(:environment_id, :full_name, :document_type, :document_value, :customer_type)
end
And this is the server log:
Started POST "/api/v1/customers" for 127.0.0.1 at 2021-06-02 18:18:44 +0100
Processing by Api::V1::CustomersController#create as HTML
Parameters: {"full_name"=>"cvbcv", "document_type"=>"bcvbc", "document_value"=>"vbcvbcv", "customer_type"=>"bcvbcvb", "environment_id"=>1, "customer"=>{"full_name"=>"cvbcv", "document_type"=>"bcvbc", "document_value"=>"vbcvbcv", "customer_type"=>"bcvbcvb", "environment_id"=>1}}
User Load (0.7ms) SELECT "users".* FROM "users" WHERE "users"."uid" = $1 LIMIT $2 [["uid", "test1#test.cl"], ["LIMIT", 1]]
(0.5ms) BEGIN
↳ app/services/api/customers.rb:20:in `create_customer'
Environment Load (0.5ms) SELECT "environments".* FROM "environments" WHERE "environments"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
↳ app/services/api/customers.rb:20:in `create_customer'
Customer Create (1.4ms) INSERT INTO "customers" ("full_name", "document_type", "document_value", "customer_type", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "id" [["full_name", "cvbcv"], ["document_type", "bcvbc"], ["document_value", "vbcvbcv"], ["customer_type", "bcvbcvb"], ["created_at", "2021-06-02 17:18:44.378509"], ["updated_at", "2021-06-02 17:18:44.378509"]]
↳ app/services/api/customers.rb:20:in `create_customer'
(1.2ms) ROLLBACK
↳ app/services/api/customers.rb:20:in `create_customer'
Completed 500 Internal Server Error in 22ms (ActiveRecord: 4.3ms | Allocations: 6384)
ActiveRecord::NotNullViolation (PG::NotNullViolation: ERROR: null value in column "environment_id" violates not-null constraint
DETAIL: Failing row contains (28, cvbcv, bcvbc, vbcvbcv, bcvbcvb, null, 2021-06-02 17:18:44.378509, 2021-06-02 17:18:44.378509).
):
I tried everything i can possible know, i triple checked the migrations and schema.
I also checked active record gem version from previous projects and everything is ok.
One thing that might be relevant, this that the project have a front-end in Angular.
Rails validates for Environment presence in the database because Customer belongs_to to it.
Query below in your log is trying to load Environment with ID=1:
Environment Load (0.5ms) SELECT "environments".* FROM "environments" WHERE "environments"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
After this Rails try to INSERT new Customer without Environment reference because it seems that Environment with ID=1 doesn't exist in the database.
multiple ways of solving it.
problem: the environment_id is nil and already as not nil defined in the postgres DB.
you can add a required: true to the association, which rails then checks for existence. in your case a validation error would throw.
you can add a validates :enviroment_id, presence: true which would do the same
you merge the id into the params
example to always merge the environment_id into the params
def customer_params
params.require(:customer).permit(:full_name, :document_type, :document_value, :customer_type).merge(environment_id: GET_ME_THE_ENV_ID)
end

Two belong_to referring the same table + eager loading

First of all, based on this (Rails association with multiple foreign keys) I figured out how to make two belong_to pointing to the same table.
I have something like that
class Book < ApplicationRecord
belongs_to :author, inverse_of: :books
belongs_to :co_author, inverse_of: :books, class_name: "Author"
end
class Author < ApplicationRecord
has_many :books, ->(author) {
unscope(:where).
where("books.author_id = :author_id OR books.co_author_id = :author_id", author_id: author.id)
}
end
It's all good. I can do either
book.author
book.co_author
author.books
However, sometimes I need to eager load books for multiple authors (to avoid N queries).
I am trying to do something like:
Author.includes(books: :title).where(name: ["Lewis Carroll", "George Orwell"])
Rails 5 throws at me: "ArgumentError: The association scope 'books' is instance dependent (the scope block takes an argument). Preloading instance dependent scopes is not supported."
I am trying to figure out what I should do?
Should I go with many-to-many association? It sounds like a solution. However, it looks like it will introduce it's own problems (I need "ordering", meaning that I need explicitly differentiate between main author and co-author).
Just trying to figure out whether I am missing some simpler solution...
Why do you not use HABTM relation? For example:
# Author model
class Author < ApplicationRecord
has_and_belongs_to_many :books, join_table: :books_authors
end
# Book model
class Book < ApplicationRecord
has_and_belongs_to_many :authors, join_table: :books_authors
end
# Create books_authors table
class CreateBooksAuthorsTable < ActiveRecord::Migration
def change
create_table :books_authors do |t|
t.references :book, index: true, foreign_key: true
t.references :author, index: true, foreign_key: true
end
end
end
You can use eagerload like as following:
irb(main):007:0> Author.includes(:books).where(name: ["Lewis Carroll", "George Orwell"])
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" IN (?, ?) LIMIT ? [["name", "Lewis Correll"], ["name", "George Orwell"], ["LIMIT", 11]]
HABTM_Books Load (0.1ms) SELECT "books_authors".* FROM "books_authors" WHERE "books_authors"."author_id" IN (?, ?) [["author_id", 1], ["author_id", 2]]
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."id" IN (?, ?) [["id", 1], ["id", 2]]
Try this:
Author.where(name: ["Lewis Carroll", "George Orwell"]).include(:books).select(:title)

Using `assign_attributes` saves `has_many through:` association immediately

As far as I know, assign_attributes (unlike update_attributes) is not supposed to save the record or for that matter, any record.
So it quite startled me when I discovered that this is not true when supplying _ids for a has_many through: relation.
Consider the following example:
class GroupUser < ApplicationRecord
belongs_to :group
belongs_to :user
end
class Group < ApplicationRecord
has_many :group_users
has_many :users, through: :group_users
end
class User < ApplicationRecord
has_many :group_users
has_many :groups, through: :group_users
validates :username, presence: true
end
So we have users and groups in an m-to-m relationship.
Group.create # Create group with ID 1
Group.create # Create group with ID 2
u = User.create(username: 'Johny')
# The following line inserts two `GroupUser` join objects, despite the fact
# that we have called `assign_attributes` instead of `update_attributes`
# and, equally disturbing, the user object is not even valid as we've
# supplied an empty `username` attribute.
u.assign_attributes(username: '', group_ids: [1, 26])
The log as requested by a commenter:
irb(main):013:0> u.assign_attributes(username: '', group_ids: [1, 2])
Group Load (0.2ms) SELECT "groups".* FROM "groups" WHERE "groups"."id" IN (1, 2)
Group Load (0.1ms) SELECT "groups".* FROM "groups" INNER JOIN "group_users" ON "groups"."id" = "group_users"."group_id" WHERE "group_users"."user_id" = ? [["user_id", 1]]
(0.0ms) begin transaction
SQL (0.3ms) INSERT INTO "group_users" ("group_id", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["group_id", 1], ["user_id", 1], ["created_at", "2017-06-29 08:15:11.691941"], ["updated_at", "2017-06-29 08:15:11.691941"]]
SQL (0.1ms) INSERT INTO "group_users" ("group_id", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["group_id", 2], ["user_id", 1], ["created_at", "2017-06-29 08:15:11.693984"], ["updated_at", "2017-06-29 08:15:11.693984"]]
(2.5ms) commit transaction
=> nil
I daresay that update_attributes and the _ids construct are mostly used for processing web forms - in this case a form that updates the user itself as well as its group association. So I think it is quite safe to say that the general assumption here is all or nothing, and not a partial save.
Am I using it wrong in some way?
#gokul-m suggests reading about the issue at https://github.com/rails/rails/issues/17368. One of the comments in there points to a temporary workaround: https://gist.github.com/remofritzsche/4204e399e547ff7e3afdd0d89a5aaf3e
an example of my solution to this problem:
ruby:
def assign_parameters(attributes, options = {})
with_transaction_returning_status {self.assign_attributes(attributes, options)}
end
You can handle validation with assign_attributes like so
#item.assign_attributes{ year: "2021", type: "bad" }.valid?

stack too deep from callback method

I have four models in Sport, Gold, Silver, and Bronze with a one to one relationship between sport and the other three. Every instance of sport must have an instance of Gold, Silver and Bronze; and three are to be created using a callback function in the Sports model. These callback function is one throwing up the error that i have been able to trace as the rails error stack produced is just a single line.
My Code
MODELS
Sport
class Sport < ActiveRecord::Base
validates :sportname, presence: true,
uniqueness: { case_sensitive: false }
has_one :gold, inverse_of: :sport, :dependent => :destroy
has_one :silver, inverse_of: :sport, :dependent => :destroy
has_one :bronze, inverse_of: :sport, :dependent => :destroy
accepts_nested_attributes_for :gold
accepts_nested_attributes_for :silver
accepts_nested_attributes_for :bronze
after_validation :build_default_medals, on: :create
def build_default_medals
self.build_gold
self.build_silver
self.build_bronze
end
end
Gold
class Gold < ActiveRecord::Base
belongs_to :sport #, inverse_of: :gold
validates_associated :sport, presence: true
belongs_to :team, inverse_of: :golds, counter_cache: true
validates_associated :team, :if => :create, allow_nil: true
accepts_nested_attributes_for :team
accepts_nested_attributes_for :sport
end
Silver
class Silver < ActiveRecord::Base
belongs_to :sport #, inverse_of: :silver
validates_associated :sport, presence: true
belongs_to :team, inverse_of: :silvers, counter_cache: true
validates_associated :team, :if => :create, allow_nil: true
accepts_nested_attributes_for :team
accepts_nested_attributes_for :sport
end
Bronze
class Bronze < ActiveRecord::Base
belongs_to :sport #, inverse_of: :bronze
validates_associated :sport, presence: true
belongs_to :team, inverse_of: :bronzes, counter_cache: true
validates_associated :team, :if => :create, allow_nil: true
accepts_nested_attributes_for :team
accepts_nested_attributes_for :sport
end
The callback after_validation :build_default_medals, on: :create is where the error is, producing this in my log
Started POST "/admin/sports" for 127.0.0.1 at 2016-07-14 14:52:22 -0400
Processing by Admin::SportsController#create as HTML
Parameters:{"utf8"=>"√",authenticity_token"=>"jss1O4bSJd3hcqxuSpu/KAxaowB7d
g5pLZw55oGDf1M=", "sport"=>{"sportname"=>"boxing"}, "commit"=>"Create Sport"}
User Load (1.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1 (0.0ms) BEGIN
Sport Exists (1.0ms) SELECT 1 AS one FROM `sports` WHERE `sports`.`sportname` = 'boxing' LIMIT 1
SQL (1.0ms) INSERT INTO `sports` (`created_at`, `sportname`, `updated_at`) VALUES ('2016-07-14 18:52:22', 'boxing', '2016-07-14 18:52:22')
SQL (15.1ms) UPDATE `sports` SET `created_at` = '2016-07-14 18:52:22', `id` = 13, `sportname` = 'boxing', `updated_at` = '2016-07-14 18:52:22' WHERE `sports`.`id` = 13
SQL (1.0ms) INSERT INTO `golds` (`created_at`, `sport_id`, `updated_at`) VALUES ('2016-07-14 18:52:22', 13, '2016-07-14 18:52:22')
SQL (1.0ms) UPDATE `sports` SET `created_at` = '2016-07-14 18:52:22', `id` =13, `sportname` = 'boxing', `updated_at` = '2016-07-14 18:52:22' WHERE `sports`.`id` = 13
SQL (1.0ms) INSERT INTO `silvers` (`created_at`, `sport_id`, `updated_at`) VALUES ('2016-07-14 18:52:22', 13, '2016-07-14 18:52:22')
SQL (1.0ms) UPDATE `sports` SET `created_at` = '2016-07-14 18:52:22', `id` =13, `sportname` = 'boxing', `updated_at` = '2016-07-14 18:52:22' WHERE `sports`.`id` = 13
SQL (1.0ms) INSERT INTO `bronzes` (`created_at`, `sport_id`, `updated_at`) VALUES ('2016-07-14 18:52:22', 13, '2016-07-14 18:52:22') (72.2ms)
ROLLBACK
Completed 500 Internal Server Error in 213ms
SystemStackError (stack level too deep): actionpack (4.1.8) lib/action_dispatch/middleware/reloader.rb:79
Rendered C:/RailsInstaller/Ruby2.1.0/lib/ruby/gems/2.1.0/gems/actionpack-4.1.8/lib/action_dispatch/middleware/templates/rescues/_source.erb (2.0ms)
Rendered C:/RailsInstaller/Ruby2.1.0/lib/ruby/gems/2.1.0/gems/actionpack-4.1.8/lib/action_dispatch/middleware/templates/rescues/_trace.html.erb (4.0ms)
Rendered C:/RailsInstaller/Ruby2.1.0/lib/ruby/gems/2.1.0/gems/actionpack-4.1.8/lib/action_dispatch/middleware/templates/rescues/_request_and_response.html.erb (7.0ms)
Rendered C:/RailsInstaller/Ruby2.1.0/lib/ruby/gems/2.1.0/gems/actionpack-4.1.8/lib/action_dispatch/middleware/templates/rescues/diagnostics.erb within rescues/layout (145.1ms)
I am curious as to why the callback triggers update after inserting and what exactly is triggering the ROLLBACK and infinite loop for Stack Level Too Deep. Any insight is appreciated. Thanks.
EDIT
Using after_create :build_default_medals in place of after_validation :build_default_medals, on: :creation produces
Started POST "/admin/sports" for 127.0.0.1 at 2016-07-14 16:19:24 -0400
Processing by Admin::SportsController#create as HTML
Parameters: {"utf8"=>"√","authenticity_token"=>"3LrwB6+nD9PJ9EwxEgIGhN3rVHP3UPLOHUz9MXWRJ4Y=", "sport"=>{"sportname"=>"dancing"}, "commit"=>"Create Sport"}
User Load (1.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1 (0.0ms) BEGIN
Sport Exists (1.0ms) SELECT 1 AS one FROM `sports` WHERE `sports`.`sportname` = 'dancing' LIMIT 1
SQL (1.0ms) INSERT INTO `sports` (`created_at`, `sportname`,`updated_at`) VALUES ('2016-07-14 20:19:24', 'dancing', '2016-07-14 20:19:24')
Gold Load (2.0ms) SELECT `golds`.* FROM `golds` WHERE `golds`.`sport_id` =8 LIMIT 1
Silver Load (1.0ms) SELECT `silvers`.* FROM `silvers` WHERE `silvers`.`sport_id` = 8 LIMIT 1
Bronze Load (1.0ms) SELECT `bronzes`.* FROM `bronzes` WHERE `bronzes`.`sport_id` = 8 LIMIT 1
(45.5ms) COMMIT
EDIT FOR undefined method 'create' for #<Gold:0x63bf820>
def build_default_medals
#Gold.create(sport: self, team: nil)
#Silver.create(sport: self, team: nil)
#Bronze.create(sport: self, team: nil)
self.create_gold(team: nil)
self.create_silver(team: nil)
self.create_bronze(team: nil)
end
replace
after_validation :build_default_medals, on: :create
with
after_create :build_default_medals
You are trying to build associated models while sport is still a new record and not a persisted one. validates_associated :sport, presence: true causes a circular dependency and will give error.
I reproduced and tested with this change. It will work fine.
For saved object, use self.create_gold instead of self.build_gold, like this
def build_default_medals
self.create_gold
self.create_silver
self.create_bronze
end
Also remove accepts_nested_attributes_for :sport from Gold, Silver and Bronze model. Refer - https://github.com/rails/rails/issues/7809

Resources