I’m trying to return the attribute from an intermediate table with related record in an index request, based on a different SO response I have:
class Bus < ApplicationRecord
has_many :buses_passengers, dependent: :destroy, inverse_of: :bus
has_many :passengers, through: :buses_passengers, inverse_of: :buses
has_many :passengers_with_ticket_type, -> { select('passengers.*, buses_passengers.ticket_type') },
class_name: 'Passenger',
through: :buses_passengers,
source: :passenger
end
class BusesPassenger < ApplicationRecord
belongs_to :bus, class_name: 'Bus', foreign_key: 'bus_id', inverse_of: :buses_passengers
belongs_to :passenger
enum ticket_type: { type_a: 0, type_b: 1, type_c: 2 }
end
class Passenger < ApplicationRecord
has_many :buses_passengers, dependent: :destroy
has_many :buses, through: :buses_passengers, class_name: 'Bus',
foreign_key: 'bus_id',
inverse_of: :passengers
end
In the controller I do
@buses = Bus.includes(:passengers_with_ticket_type).some_scope
And in the view I want to iterate on every bus to also include all its passengers and also return inside the passenger object, which ticket_type they have. Something like this but in a view so of course different:
@buses.each do |bus|
puts bus.attribute_1
...
bus.passengers_with_ticket_type do |passenger|
puts passenger.attribute_1
...
puts passenger.ticket_type
end
end
This fails though, with
ActiveRecord::StatementInvalid - PG::UndefinedTable: ERROR: missing FROM-clause entry for table "buses_passengers"
LINE 1: SELECT passengers.*, buses_passengers.ticket_type FROM "users...
and I cannot find a way to add the from
clause to the includes
. This does work for individual entries though, so
Bus.last.passengers_with_ticket_type.last.ticket_type
works like a charm, but when trying to use includes
it just breaks.
Any idea how to fix this? Or some other approach to this?
TIA
Let’s start by fixing the naming issues and removing all the cruft from the models:
class Bus < ApplicationRecord
has_many :bus_passengers, dependent: :destroy
has_many :passengers, through: :bus_passengers
end
class BusPassenger < ApplicationRecord
belongs_to :bus
belongs_to :passenger
enum ticket_type: { type_a: 0, type_b: 1, type_c: 2 }
end
class Passenger < ApplicationRecord
has_many :bus_passengers, dependent: :destroy
has_many :buses, through: :bus_passengers
end
class RenameBusesPassengers < ActiveRecord::Migration[7.0]
def change
rename_table :buses_passengers, :bus_passengers
end
end
When naming models you should use SingularSingular
and not PluralSingular
. That’s because when ActiveRecord derives a class name from a table name plural segments will be treated as module nesting and you will get a constant missing error when it looks for Buses::Passenger
. That way it just works even without extra configuration.
Of course using a name that actually fits the domain like Ticket
would be superior to the fallback of just moshing the names of the two things it joins together.
You do not need to set inverses, foreign keys etc. when they can be derived from the name of the association or class name. Having less noise lets you focus on the things that should actually stick out when reading the code.
The reason Postgres can’t find the table is because .includes
tries to be smart and if you don’t reference the joined tables it will use .preload
to load the records in two queries:
# This fires SELECT * FROM buses ...
buses = Bus.includes(bus_passengers: :passenger)
buses.each do |bus|
# the additional query is fired here on the first iteration
bus.buses_passengers do |buses_passenger|
# ...
end
end
You can think of it like lazy loading an association. If you want to do it in a single query upfront use .eager_load
instead.
However even if you didn’t get the missing table error Postgres would not let you do select('passengers.*, buses_passengers.ticket_type')
as it’s ambigous. When you select columns of a joined table they must be aggregates or included in the GROUP BY clause of the query.
But you don’t even need this in the first place. Just let includes
/ eager_load
get all the columns and optimize it when it actually becomes an issue.
See:
- Convention over Configuration in Active Record
- Preload, Eagerload, Includes and Joins
1
This is what I thought of after taking a step back and rethinking it.
- Remove the extra has_many, I will not be trying to use it anymore
class Bus < ApplicationRecord
has_many :buses_passengers, dependent: :destroy, inverse_of: :bus
has_many :passengers, through: :buses_passengers, inverse_of: :buses
end
class BusesPassenger < ApplicationRecord
belongs_to :bus, class_name: 'Bus', foreign_key: 'bus_id', inverse_of: :buses_passengers
belongs_to :passenger
enum ticket_type: { type_a: 0, type_b: 1, type_c: 2 }
end
class Passenger < ApplicationRecord
has_many :buses_passengers, dependent: :destroy
has_many :buses, through: :buses_passengers, class_name: 'Bus',
foreign_key: 'bus_id',
inverse_of: :passengers
end
- In the controller, include the intermediate table and then the associated record.
@buses = Bus.includes(buses_passengers: [:passenger]).some_scope
- Iterate over the intermediate table and just get the other record while doing so.
@buses.each do |bus|
puts bus.attribute_1
...
bus.buses_passengers do |buses_passenger|
passenger = buses_passenger.passenger
puts passenger.attribute_1
...
puts passenger.attribute_n
puts buses_passenger.ticket_type
end
end
This seems to work, I’ll mark it as correct in a couple of days unless I get a different response or find that it doesn’t work.