Skip to content

Commit

Permalink
Created an unscope method for removing relations from a chain of
Browse files Browse the repository at this point in the history
relations. Specific where values can be unscoped, and the unscope method
still works when relations are merged or combined.
  • Loading branch information
wangjohn committed Mar 4, 2013
1 parent 9ee6f3c commit 2938754
Show file tree
Hide file tree
Showing 4 changed files with 275 additions and 0 deletions.
16 changes: 16 additions & 0 deletions activerecord/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
## Rails 4.0.0 (unreleased) ##

* Added functionality to unscope relations in a relations chain. For
instance, if you are passed in a chain of relations as follows:

Posts.select(:name => "John").order('id DESC')

but you want to get rid of order, then this feature allows you to do:

Posts.select(:name => "John").order("id DESC").unscope(:order)
== Posts.select(:name => "John")

The .unscope() function is more general than the .except() method because
.except() only works on the relation it is acting on. However, .unscope()
works for any relation in the entire relation chain.

*John Wang*

* Postgresql timestamp with time zone (timestamptz) datatype now returns a
ActiveSupport::TimeWithZone instance instead of a string

Expand Down
94 changes: 94 additions & 0 deletions activerecord/lib/active_record/relation/query_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,67 @@ def reorder!(*args) # :nodoc:
self
end

VALID_UNSCOPING_VALUES = Set.new([:where, :select, :group, :order, :lock,
:limit, :offset, :joins, :includes, :from,
:readonly, :having])

# Removes an unwanted relation that is already defined on a chain of relations.
# This is useful when passing around chains of relations and would like to
# modify the relations without reconstructing the entire chain.
#
# User.all.order('email DESC').unscope(:order) == User.all
#
# The method arguments are symbols which correspond to the names of the methods
# which should be unscoped. The valid arguments are given in VALID_UNSCOPING_VALUES.
# The method can also be called with multiple arguments. For example:
#
# User.all.order('email DESC').select('id').where(:name => "John")
# .unscope(:order, :select, :where) == User.all
#
# One can additionally pass a hash as an argument to unscope specific :where values.
# This is done by passing a hash with a single key-value pair. The key should be
# :where and the value should be the where value to unscope. For example:
#
# User.all.where(:name => "John", :active => true).unscope(:where => :name)
# == User.all.where(:active => true)
#
# Note that this method is more generalized than ActiveRecord::SpawnMethods#except
# because #except will only affect a particular relation's values. It won't wipe
# the order, grouping, etc. when that relation is merged. For example:
#
# Post.comments.except(:order)
#
# will still have an order if it comes from the default_scope on Comment.
def unscope(*args)
check_if_method_has_arguments!("unscope", args)
spawn.unscope!(*args)
end

def unscope!(*args)
args.flatten!

args.each do |scope|
case scope
when Symbol
symbol_unscoping(scope)
when Hash
scope.each do |key, target_value|
if key != :where
raise ArgumentError, "Hash arguments in .unscope(*args) must have :where as the key."
end

Array(target_value).each do |val|
where_unscoping(val)
end
end
else
raise ArgumentError, "Unrecognized scoping: #{args.inspect}. Use .unscope(where: :attribute_name) or .unscope(:order), for example."
end
end

self
end

# Performs a joins on +args+:
#
# User.joins(:posts)
Expand Down Expand Up @@ -762,6 +823,39 @@ def build_arel

private

def symbol_unscoping(scope)
if !VALID_UNSCOPING_VALUES.include?(scope)
raise ArgumentError, "Called unscope() with invalid unscoping argument ':#{scope}'. Valid arguments are :#{VALID_UNSCOPING_VALUES.to_a.join(", :")}."
end

single_val_method = Relation::SINGLE_VALUE_METHODS.include?(scope)
unscope_code = :"#{scope}_value#{'s' unless single_val_method}="

case scope
when :order
self.send(:reverse_order_value=, false)
result = []
else
result = [] unless single_val_method
end

self.send(unscope_code, result)
end

def where_unscoping(target_value)
target_value_sym = target_value.to_sym

where_values.reject! do |rel|
case rel
when Arel::Nodes::In, Arel::Nodes::Equality
subrelation = (rel.left.kind_of?(Arel::Attributes::Attribute) ? rel.left : rel.right)
subrelation.name.to_sym == target_value_sym
else
raise "unscope(where: #{target_value.inspect}) failed: unscoping #{rel.class} is unimplemented."
end
end
end

def custom_join_ast(table, joins)
joins = joins.reject { |join| join.blank? }

Expand Down
144 changes: 144 additions & 0 deletions activerecord/test/cases/relation_scoping_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,150 @@ def test_order_after_reorder_combines_orders
assert_equal expected, received
end

def test_unscope_overrides_default_scope
expected = Developer.all.collect { |dev| [dev.name, dev.id] }
received = Developer.order('name ASC, id DESC').unscope(:order).collect { |dev| [dev.name, dev.id] }
assert_equal expected, received
end

def test_unscope_after_reordering_and_combining
expected = Developer.order('id DESC, name DESC').collect { |dev| [dev.name, dev.id] }
received = DeveloperOrderedBySalary.reorder('name DESC').unscope(:order).order('id DESC, name DESC').collect { |dev| [dev.name, dev.id] }
assert_equal expected, received

expected_2 = Developer.all.collect { |dev| [dev.name, dev.id] }
received_2 = Developer.order('id DESC, name DESC').unscope(:order).collect { |dev| [dev.name, dev.id] }
assert_equal expected_2, received_2

expected_3 = Developer.all.collect { |dev| [dev.name, dev.id] }
received_3 = Developer.reorder('name DESC').unscope(:order).collect { |dev| [dev.name, dev.id] }
assert_equal expected_3, received_3
end

def test_unscope_with_where_attributes
expected = Developer.order('salary DESC').collect { |dev| dev.name }
received = DeveloperOrderedBySalary.where(name: 'David').unscope(where: :name).collect { |dev| dev.name }
assert_equal expected, received

expected_2 = Developer.order('salary DESC').collect { |dev| dev.name }
received_2 = DeveloperOrderedBySalary.select("id").where("name" => "Jamis").unscope({:where => :name}, :select).collect { |dev| dev.name }
assert_equal expected_2, received_2

expected_3 = Developer.order('salary DESC').collect { |dev| dev.name }
received_3 = DeveloperOrderedBySalary.select("id").where("name" => "Jamis").unscope(:select, :where).collect { |dev| dev.name }
assert_equal expected_3, received_3
end

def test_unscope_multiple_where_clauses
expected = Developer.order('salary DESC').collect { |dev| dev.name }
received = DeveloperOrderedBySalary.where(name: 'Jamis').where(id: 1).unscope(where: [:name, :id]).collect { |dev| dev.name }
assert_equal expected, received
end

def test_unscope_with_grouping_attributes
expected = Developer.order('salary DESC').collect { |dev| dev.name }
received = DeveloperOrderedBySalary.group(:name).unscope(:group).collect { |dev| dev.name }
assert_equal expected, received

expected_2 = Developer.order('salary DESC').collect { |dev| dev.name }
received_2 = DeveloperOrderedBySalary.group("name").unscope(:group).collect { |dev| dev.name }
assert_equal expected_2, received_2
end

def test_unscope_with_limit_in_query
expected = Developer.order('salary DESC').collect { |dev| dev.name }
received = DeveloperOrderedBySalary.limit(1).unscope(:limit).collect { |dev| dev.name }
assert_equal expected, received
end

def test_order_to_unscope_reordering
expected = DeveloperOrderedBySalary.all.collect { |dev| [dev.name, dev.id] }
received = DeveloperOrderedBySalary.order('salary DESC, name ASC').reverse_order.unscope(:order).collect { |dev| [dev.name, dev.id] }
assert_equal expected, received
end

def test_unscope_reverse_order
expected = Developer.all.collect { |dev| dev.name }
received = Developer.order('salary DESC').reverse_order.unscope(:order).collect { |dev| dev.name }
assert_equal expected, received
end

def test_unscope_select
expected = Developer.order('salary ASC').collect { |dev| dev.name }
received = Developer.order('salary DESC').reverse_order.select(:name => "Jamis").unscope(:select).collect { |dev| dev.name }
assert_equal expected, received

expected_2 = Developer.all.collect { |dev| dev.id }
received_2 = Developer.select(:name).unscope(:select).collect { |dev| dev.id }
assert_equal expected_2, received_2
end

def test_unscope_offset
expected = Developer.all.collect { |dev| dev.name }
received = Developer.offset(5).unscope(:offset).collect { |dev| dev.name }
assert_equal expected, received
end

def test_unscope_joins_and_select_on_developers_projects
expected = Developer.all.collect { |dev| dev.name }
received = Developer.joins('JOIN developers_projects ON id = developer_id').select(:id).unscope(:joins, :select).collect { |dev| dev.name }
assert_equal expected, received
end

def test_unscope_includes
expected = Developer.all.collect { |dev| dev.name }
received = Developer.includes(:projects).select(:id).unscope(:includes, :select).collect { |dev| dev.name }
assert_equal expected, received
end

def test_unscope_having
expected = DeveloperOrderedBySalary.all.collect { |dev| dev.name }
received = DeveloperOrderedBySalary.having("name IN ('Jamis', 'David')").unscope(:having).collect { |dev| dev.name }
assert_equal expected, received
end

def test_unscope_errors_with_invalid_value
assert_raises(ArgumentError) do
Developer.includes(:projects).where(name: "Jamis").unscope(:stupidly_incorrect_value)
end

assert_raises(ArgumentError) do
Developer.all.unscope(:includes, :select, :some_broken_value)
end

assert_raises(ArgumentError) do
Developer.order('name DESC').reverse_order.unscope(:reverse_order)
end

assert_raises(ArgumentError) do
Developer.order('name DESC').where(name: "Jamis").unscope()
end
end

def test_unscope_errors_with_non_where_hash_keys
assert_raises(ArgumentError) do
Developer.where(name: "Jamis").limit(4).unscope(limit: 4)
end

assert_raises(ArgumentError) do
Developer.where(name: "Jamis").unscope("where" => :name)
end
end

def test_unscope_errors_with_non_symbol_or_hash_arguments
assert_raises(ArgumentError) do
Developer.where(name: "Jamis").limit(3).unscope("limit")
end

assert_raises(ArgumentError) do
Developer.select("id").unscope("select")
end

assert_raises(ArgumentError) do
Developer.select("id").unscope(5)
end
end

def test_order_in_default_scope_should_not_prevail
expected = Developer.all.merge!(:order => 'salary').to_a.collect { |dev| dev.salary }
received = DeveloperOrderedBySalary.all.merge!(:order => 'salary').to_a.collect { |dev| dev.salary }
Expand Down
21 changes: 21 additions & 0 deletions guides/source/active_record_querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,27 @@ The SQL that would be executed:
SELECT * FROM posts WHERE id > 10 LIMIT 20
```

### `unscope`

The `except` method does not work when the relation is merged. For example:

```ruby
Post.comments.except(:order)
```

will still have an order if the order comes from a default scope on Comment. In order to remove all ordering, even from relations which are merged in, use unscope as follows:

```ruby
Post.order('id DESC').limit(20).unscope(:order) = Post.limit(20)
Post.order('id DESC').limit(20).unscope(:order, :limit) = Post.all
```

You can additionally unscope specific where clauses. For example:

```ruby
Post.where(:id => 10).limit(1).unscope(:where => :id, :limit).order('id DESC') = Post.order('id DESC')
```

### `only`

You can also override conditions using the `only` method. For example:
Expand Down

4 comments on commit 2938754

@henrik
Copy link
Contributor

@henrik henrik commented on 2938754 Mar 4, 2013

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guide isn't clear about why you'd ever want except when you have unscoped. Other than the seemingly unlikely case where you only want to remove a condition if it's not from a merge. Could except be deprecated/removed? Or unscope replace except, to keep the except/only pairing?

@wangjohn
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@henrik I was talking to @jeremy about this, and I think we might want to deprecate except at some point. I believe that the unscope method does a superset of the functions that except performed.

@henrik
Copy link
Contributor

@henrik henrik commented on 2938754 Mar 5, 2013

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wangjohn Sounds good. Thanks!

@jeremy
Copy link
Member

@jeremy jeremy commented on 2938754 Mar 5, 2013

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other than the seemingly unlikely case where you only want to remove a condition if it's not from a merge

@henrik check out how #except is used internally. Agreed that it'll probably end up deprecated as public API.

Please sign in to comment.