Skip to content

Instantly share code, notes, and snippets.

@j-mcnally
Last active October 26, 2021 23:57
Show Gist options
  • Star 32 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save j-mcnally/250eaaceef234dd8971b to your computer and use it in GitHub Desktop.
Save j-mcnally/250eaaceef234dd8971b to your computer and use it in GitHub Desktop.
OR'ing scopes
module ActiveRecord
module Querying
delegate :or, :to => :all
end
end
module ActiveRecord
module QueryMethods
# OrChain objects act as placeholder for queries in which #or does not have any parameter.
# In this case, #or must be chained with any other relation method to return a new relation.
# It is intended to allow .or.where() and .or.named_scope.
class OrChain
def initialize(scope)
@scope = scope
end
def method_missing(method, *args, &block)
right_relation = @scope.klass.unscoped do
@scope.klass.send(method, *args, &block)
end
@scope.or(right_relation)
end
end
# Returns a new relation, which is the result of filtering the current relation
# according to the conditions in the arguments, joining WHERE clauses with OR
# operand, contrary to the default behaviour that uses AND.
#
# #or accepts conditions in one of several formats. In the examples below, the resulting
# SQL is given as an illustration; the actual query generated may be different depending
# on the database adapter.
#
# === without arguments
#
# If #or is used without arguments, it returns an ActiveRecord::OrChain object that can
# be used to chain queries with any other relation method, like where:
#
# Post.where("id = 1").or.where("id = 2")
# # SELECT `posts`.* FROM `posts` WHERE (('id = 1' OR 'id = 2'))
#
# It can also be chained with a named scope:
#
# Post.where("id = 1").or.containing_the_letter_a
# # SELECT `posts`.* FROM `posts` WHERE (('id = 1' OR 'body LIKE \\'%a%\\''))
#
# === ActiveRecord::Relation
#
# When #or is used with an ActiveRecord::Relation as an argument, it merges the two
# relations, with the exception of the WHERE clauses, that are joined using the OR
# operand.
#
# Post.where("id = 1").or(Post.where("id = 2"))
# # SELECT `posts`.* FROM `posts` WHERE (('id = 1' OR 'id = 2'))
#
# === anything you would pass to #where
#
# #or also accepts anything that could be passed to the #where method, as
# a shortcut:
#
# Post.where("id = 1").or("id = ?", 2)
# # SELECT `posts`.* FROM `posts` WHERE (('id = 1' OR 'id = 2'))
#
def or(opts = :chain, *rest)
if opts == :chain
OrChain.new(self)
else
left = with_default_scope
right = (ActiveRecord::Relation === opts) ? opts : klass.unscoped.where(opts, rest)
unless left.where_values.empty? || right.where_values.empty?
left.where_values = [left.where_ast.or(right.where_ast)]
right.where_values = []
end
left = left.merge(right)
end
end
# Returns an Arel AST containing only where_values
def where_ast
arel_wheres = []
where_values.each do |where|
arel_wheres << (String === where ? Arel.sql(where) : where)
end
return Arel::Nodes::And.new(arel_wheres) if arel_wheres.length >= 2
if Arel::Nodes::SqlLiteral === arel_wheres.first
Arel::Nodes::Grouping.new(arel_wheres.first)
else
arel_wheres.first
end
end
end
end
@8vius
Copy link

8vius commented Jan 23, 2015

I'm using the updated version you posted and when I run this:

User.where(id: 1).or.where(id: 10)

The executed query is the following:

SELECT "users".* FROM "users" WHERE (((1=0) OR "users"."id" = 10))

Any idea how I can fix this?

@shuber
Copy link

shuber commented Jan 26, 2015

@8vius I believe this is another way that you can write your specific query:

User.where(id: [1, 10])

@8vius
Copy link

8vius commented Jan 26, 2015

@shuber of course, but it's just a test I'm doing and that issue came up. The same happens with queries that are actually complex.

@mglenn
Copy link

mglenn commented Jan 28, 2015

Getting the same thing here.

contacts = Contact.where("first_name='Michael'").or.where("first_name='Steve'")

executes

SELECT "contacts".* FROM "contacts"  WHERE (((1=0) OR (first_name='Steve')))

What is the following attempting to accomplish?

left = respond_to?(:with_default_scope) ? with_default_scope : klass.where("1=0")

@craigweston
Copy link

I was able to get the issues noted above by @mglenn and @8vius working by removing the following:

 left = respond_to?(:with_default_scope) ? with_default_scope : klass.where("1=0")

and replacing with self.

 left = self

The default scope still gets applied, so I'm not sure why the above line was necessary (please comment if there is a reason I am missing).

However, I did notice that this caused the default_scope to be incorrectly grouped together with the OR conditions.

For instance, given a class with the following scopes:

class Publication < ActiveRecord::Base
     default_scope { where(published: true) }
     scope :books, -> { where(pub_type: 'book') }
     scope :journals, -> { where(pub_type: 'journal') }

And a query such as:

Publication.books.or(Publication.journals)

This would produce a WHERE condition such as:

 (`publication`.`published` = 1 AND `publication`.`pub_type` = 'journal' OR `publication`.`published` = 1 AND `publication`.`pub_type` = 'book')

To fix this, I ensure that each scope is returned in a group by wrapping:

 return Arel::Nodes::And.new(arel_wheres) if arel_wheres.length >= 2

with:

return Arel::Nodes::Grouping.new(Arel::Nodes::And.new(arel_wheres)) if arel_wheres.length >= 2

This produces:

((`publication`.`published` = 1 AND `publication`.`pub_type` = 'journal') OR (`publication`.`published` = 1 AND `publication`.`pub_type` = 'book'))

BTW, this was only tested against Rails 4.2.

You can see the fork here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment