ActiveRecord .where.missing generates wrong SQL when association with explicite "class_name" matches model's table name
Steps to reproduce
Models definition with namespace:
# app/models/stock_snapshots.rb
module StockSnapshots
def self.table_name_prefix = "stock_snapshots_"
end
# app/models/stock_snapshots/zones_group.rb
module StockSnapshots
class ZonesGroup < ApplicationRecord
has_many :stock_snapshots_zones_groups, class_name: "StockSnapshots::StockSnapshotsZonesGroup"
...
end
end
# app/models/stock_snapshots/stock_snapshots_zones_group.rb
module StockSnapshots
class StockSnapshotsZonesGroup < ApplicationRecord
belongs_to :zones_group, class_name: "StockSnapshots::ZonesGroup"
...
end
end
In order to find StockSnapshots::ZonesGroup without associated StockSnapshots::StockSnapshotsZonesGroup:
StockSnapshots::ZonesGroup.where.missing(:stock_snapshots_zones_groups)
Expected behavior
The .where.missing query generates the following SQL:
SELECT "stock_snapshots_zones_groups".* FROM "stock_snapshots_zones_groups" LEFT OUTER JOIN "stock_snapshots_stock_snapshots_zones_groups" ON "stock_snapshots_stock_snapshots_zones_groups"."zones_group_id" = "stock_snapshots_zones_groups"."id" WHERE "stock_snapshots_stock_snapshots_zones_groups"."id" IS NULL
Actual behavior
4.0.2 :001 > puts StockSnapshots::ZonesGroup.where.missing(:stock_snapshots_zones_groups).to_sql
SELECT "stock_snapshots_zones_groups".* FROM "stock_snapshots_zones_groups" LEFT OUTER JOIN "stock_snapshots_stock_snapshots_zones_groups" "stock_snapshots_zones_groups_stock_snapshots_zones_groups" ON "stock_snapshots_zones_groups_stock_snapshots_zones_groups"."zones_group_id" = "stock_snapshots_zones_groups"."id" WHERE "stock_snapshots_zones_groups"."id" IS NULL
=> nil
The where clause is completely wrong since "stock_snapshots_zones_groups" is the main table rather than the alias name ("stock_snapshots_zones_groups_stock_snapshots_zones_groups") generated by "left_joins".
When checking the source code:
def missing(*associations)
associations.each do |association|
reflection = scope_association_reflection(association)
@scope.left_outer_joins!(association)
association_conditions = Array(reflection.association_primary_key).index_with(nil)
if reflection.options[:class_name]
# ==> THE BUG IS RIGHT HERE: "association" may perfectly matches the main table's name
@scope.where!(association => association_conditions)
else
@scope.where!(reflection.table_name => association_conditions)
end
end
@scope
end
If the options[:class_name] is specified, then AR simply take the association name rather than the alias name of the main table generated by left_outer_joins!. Unfortunately the association name in this case perfectly matches the main table name ("stock_snapshots_zones_groups")...
⚠️ Not tested, but I guess the same issue DOES ALSO EXIST in #associated method.
Workaround
4.0.2 :002 > puts StockSnapshots::ZonesGroup.left_joins(:stock_snapshots_zones_groups).where(stock_snapshots_stock_snapshots_zones_groups: { id: nil }).to_sql
SELECT "stock_snapshots_zones_groups".* FROM "stock_snapshots_zones_groups" LEFT OUTER JOIN "stock_snapshots_stock_snapshots_zones_groups" ON "stock_snapshots_stock_snapshots_zones_groups"."zones_group_id" = "stock_snapshots_zones_groups"."id" WHERE "stock_snapshots_stock_snapshots_zones_groups"."id" IS NULL
=> nil
The basic query combining "left_joins" and "where" generates the correct SQL.
System configuration
Rails version: 8.1.3
Ruby version: ruby 4.0.2
Source: rails/rails