Finding record where polymorphic association or ni

2019-07-21 18:06发布

问题:

I have a rails app running ruby 2.1.2 and rails 4.1.1 and in it I have a polymorphic association like so:

class Picture < ActiveRecord::Base
  belongs_to :imageable, polymorphic: true
end

class Employee < ActiveRecord::Base
 has_many :pictures, as: :imageable
end

class Product < ActiveRecord::Base
  has_many :pictures, as: :imageable
end

I want to be able to find all Pictures that either belong to a given Employee or have no associated "imageable" record.

# These both work fine
Picture.where(imageable: Employee.first) # finds all associated with Employee.first
Picture.where(imageable: nil) # finds all un-associated Pictures

#This does not work
Picture.where(imageable: [Employee.first, nil])

The reason it does not work is obvious once you see the SQL that is run

SELECT "pictures".* FROM "pictures"
WHERE "pictures"."imageable_type" = 'Employee' AND 
      (("pictures"."imageable_id" = 1 OR "pictures"."imageable_id" IS NULL))

When instead I need

SELECT "pictures".* FROM "pictures"
WHERE (("pictures"."imageable_type" = 'Employee' AND "pictures"."imageable_id" = 1)) OR 
      "pictures"."imageable_id" IS NULL

Any idea how to run the query in rails without writing out the SQL?

回答1:

Just try this:

from_employee = Picture.where(imageable: Employee.first).where_values.reduce(:and)
from_nil = Picture.where(imageable: nil).where_values.reduce(:and)

@from_employee_or_nil = Picture.where(from_employee.or(from_nil))

Edit

On Rails 4.1 the where_values is defined dynamicaly at query_methods. It map the Arel::Nodes "Conditions" of all the conditions related to the current ActiveRecord::Relation. This nodes respond to and(other) and with reduce(:and) you can get all of them together.

On Rails 4.2 this doesn't work. You can make it work, but it turns odd (see: OR operator in WHERE clause with Arel in Rails 4.2 -the edit on the question-). What can we do?

Use a plain query(1):

@employee = Employee.first
Picture.where("(pictures.imageable_type = ? AND pictures.imageable_id = ?) OR pictures.imageable_id IS NULL", @employee.class, @employee.id)

Hack with Arel(2): (as @cristian says)

@employee = Employee.first
Picture.where( ( Picture.arel_table[:imageable_type].eq(@employee.class).and( Picture.arel_table[:imageable_id].eq(@employee.id) )).or(Picture.arel_table[:imageable_id].eq(nil) ))

Using a complex where_values(3):

employee_scope = Picture.where(imageable: Employee.first)
nil_scope = Picture.where(imageable: nil)

Picture.where( employee_scope.where_values.reduce(:and).or(nil_scope.where_values.reduce(:and)).to_sql, employee_scope.bind_values.map(&:last) + nil_scope.bind_values.map(&:last) )

My choice: for this case, the plain query (1). My first answer (that I've been using), stop of working at 4.2 and needs a refactor (3). Arel gives you independence from db manager (I recommend to learn Arel). And the answer you publish give me some weird feel, because it works fine but it use an invalid condition: imageable_id IS NULL AND imageable_type = 'Employee' are theoretically returned by the search.



回答2:

In order to obtain the exact query you are looking for use AREL:

employee_condition = Picture.arel_table[:imageable_id]
  .eq( Employee.first )
  .and( Picture.arel_table[:imageable_type].eq('Employee') )

nil_condition = Picture.arel_table[:imageable_id].eq(nil)

Picture.where(
  Picture.arel_table.grouping(employee_condition).or(nil_condition)
)

This will generate:

SELECT "pictures".* FROM "pictures" WHERE (
  ("pictures"."imageable_id" = 1 AND "pictures"."imageable_type" = 'Employee')
  OR "pictures"."imageable_id" IS NULL
)

To understand better what is happening in the code above read Using Arel to Compose SQL Queries and Rails/Arel - precedence when combining AREL predicates



回答3:

So the following works, but I'm not sure if it is the best or even the only way:

@empolyee = Employee.first
Picture.where(imageable_type: ["Employee", nil], imageable_id: [@empolyee.id, nil])

This runs the following which I'm not sure if it is slower or not.

SELECT "pictures".* FROM "pictures"
  WHERE (("pictures"."imageable_type" = 'Organization' OR "pictures"."imageable_type" IS NULL)) AND
        (("pictures"."imageable_id" = 1 OR "pictures"."imageable_id" IS NULL))

EDIT

While this works there is a possible edge case that fails.

I think that if a Picture belong to a Product and that Product is deleted (and my dependent strategy is set to nullify) then the Picture with have null as the 'imageable_id' but still have "Product" as the 'imageable_type'.

Such a Picture would not be found by my above answer as "imageable_type" is neither "Employee" nor NULL.