diff options
Diffstat (limited to 'activerecord/test/cases/relation')
-rw-r--r-- | activerecord/test/cases/relation/delegation_test.rb | 57 | ||||
-rw-r--r-- | activerecord/test/cases/relation/merging_test.rb | 163 | ||||
-rw-r--r-- | activerecord/test/cases/relation/mutation_test.rb | 145 | ||||
-rw-r--r-- | activerecord/test/cases/relation/or_test.rb | 130 | ||||
-rw-r--r-- | activerecord/test/cases/relation/predicate_builder_test.rb | 18 | ||||
-rw-r--r-- | activerecord/test/cases/relation/record_fetch_warning_test.rb | 42 | ||||
-rw-r--r-- | activerecord/test/cases/relation/select_test.rb | 15 | ||||
-rw-r--r-- | activerecord/test/cases/relation/where_chain_test.rb | 107 | ||||
-rw-r--r-- | activerecord/test/cases/relation/where_clause_test.rb | 241 | ||||
-rw-r--r-- | activerecord/test/cases/relation/where_test.rb | 366 |
10 files changed, 1284 insertions, 0 deletions
diff --git a/activerecord/test/cases/relation/delegation_test.rb b/activerecord/test/cases/relation/delegation_test.rb new file mode 100644 index 0000000000..2696d1bb00 --- /dev/null +++ b/activerecord/test/cases/relation/delegation_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/comment" + +module ActiveRecord + module DelegationWhitelistTests + ARRAY_DELEGATES = [ + :+, :-, :|, :&, :[], :shuffle, + :all?, :collect, :compact, :detect, :each, :each_cons, :each_with_index, + :exclude?, :find_all, :flat_map, :group_by, :include?, :length, + :map, :none?, :one?, :partition, :reject, :reverse, :rotate, + :sample, :second, :sort, :sort_by, :slice, :third, :index, :rindex, + :to_ary, :to_set, :to_xml, :to_yaml, :join, + :in_groups, :in_groups_of, :to_sentence, :to_formatted_s, :as_json + ] + + ARRAY_DELEGATES.each do |method| + define_method "test_delegates_#{method}_to_Array" do + assert_respond_to target, method + end + end + end + + module DeprecatedArelDelegationTests + AREL_METHODS = [ + :with, :orders, :froms, :project, :projections, :taken, :constraints, :exists, :locked, :where_sql, + :ast, :source, :join_sources, :to_dot, :create_insert, :create_true, :create_false + ] + + def test_deprecate_arel_delegation + AREL_METHODS.each do |method| + assert_deprecated { target.public_send(method) } + assert_deprecated { target.public_send(method) } + end + end + end + + class DelegationAssociationTest < ActiveRecord::TestCase + include DelegationWhitelistTests + include DeprecatedArelDelegationTests + + def target + Post.new.comments + end + end + + class DelegationRelationTest < ActiveRecord::TestCase + include DelegationWhitelistTests + include DeprecatedArelDelegationTests + + def target + Comment.all + end + end +end diff --git a/activerecord/test/cases/relation/merging_test.rb b/activerecord/test/cases/relation/merging_test.rb new file mode 100644 index 0000000000..074ce9454f --- /dev/null +++ b/activerecord/test/cases/relation/merging_test.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/comment" +require "models/developer" +require "models/computer" +require "models/post" +require "models/project" +require "models/rating" + +class RelationMergingTest < ActiveRecord::TestCase + fixtures :developers, :comments, :authors, :author_addresses, :posts + + def test_relation_merging + devs = Developer.where("salary >= 80000").merge(Developer.limit(2)).merge(Developer.order("id ASC").where("id < 3")) + assert_equal [developers(:david), developers(:jamis)], devs.to_a + + dev_with_count = Developer.limit(1).merge(Developer.order("id DESC")).merge(Developer.select("developers.*")) + assert_equal [developers(:poor_jamis)], dev_with_count.to_a + end + + def test_relation_to_sql + post = Post.first + sql = post.comments.to_sql + assert_match(/.?post_id.? = #{post.id}\z/i, sql) + end + + def test_relation_merging_with_arel_equalities_keeps_last_equality + devs = Developer.where(Developer.arel_table[:salary].eq(80000)).merge( + Developer.where(Developer.arel_table[:salary].eq(9000)) + ) + assert_equal [developers(:poor_jamis)], devs.to_a + end + + def test_relation_merging_with_arel_equalities_keeps_last_equality_with_non_attribute_left_hand + salary_attr = Developer.arel_table[:salary] + devs = Developer.where( + Arel::Nodes::NamedFunction.new("abs", [salary_attr]).eq(80000) + ).merge( + Developer.where( + Arel::Nodes::NamedFunction.new("abs", [salary_attr]).eq(9000) + ) + ) + assert_equal [developers(:poor_jamis)], devs.to_a + end + + def test_relation_merging_with_eager_load + relations = [] + relations << Post.order("comments.id DESC").merge(Post.eager_load(:last_comment)).merge(Post.all) + relations << Post.eager_load(:last_comment).merge(Post.order("comments.id DESC")).merge(Post.all) + + relations.each do |posts| + post = posts.find { |p| p.id == 1 } + assert_equal Post.find(1).last_comment, post.last_comment + end + end + + def test_relation_merging_with_locks + devs = Developer.lock.where("salary >= 80000").order("id DESC").merge(Developer.limit(2)) + assert_predicate devs, :locked? + end + + def test_relation_merging_with_preload + [Post.all.merge(Post.preload(:author)), Post.preload(:author).merge(Post.all)].each do |posts| + assert_queries(2) { assert posts.first.author } + end + end + + def test_relation_merging_with_joins + comments = Comment.joins(:post).where(body: "Thank you for the welcome").merge(Post.where(body: "Such a lovely day")) + assert_equal 1, comments.count + end + + def test_relation_merging_with_left_outer_joins + comments = Comment.joins(:post).where(body: "Thank you for the welcome").merge(Post.left_outer_joins(:author).where(body: "Such a lovely day")) + + assert_equal 1, comments.count + end + + def test_relation_merging_with_association + assert_queries(2) do # one for loading post, and another one merged query + post = Post.where(body: "Such a lovely day").first + comments = Comment.where(body: "Thank you for the welcome").merge(post.comments) + assert_equal 1, comments.count + end + end + + test "merge collapses wheres from the LHS only" do + left = Post.where(title: "omg").where(comments_count: 1) + right = Post.where(title: "wtf").where(title: "bbq") + + merged = left.merge(right) + + assert_not_includes merged.to_sql, "omg" + assert_includes merged.to_sql, "wtf" + assert_includes merged.to_sql, "bbq" + end + + def test_merging_reorders_bind_params + post = Post.first + right = Post.where(id: 1) + left = Post.where(title: post.title) + + merged = left.merge(right) + assert_equal post, merged.first + end + + def test_merging_compares_symbols_and_strings_as_equal + post = PostThatLoadsCommentsInAnAfterSaveHook.create!(title: "First Post", body: "Blah blah blah.") + assert_equal "First comment!", post.comments.where(body: "First comment!").first_or_create.body + end + + def test_merging_with_from_clause + relation = Post.all + assert_empty relation.from_clause + relation = relation.merge(Post.from("posts")) + assert_not_empty relation.from_clause + end +end + +class MergingDifferentRelationsTest < ActiveRecord::TestCase + fixtures :posts, :authors, :author_addresses, :developers + + test "merging where relations" do + hello_by_bob = Post.where(body: "hello").joins(:author). + merge(Author.where(name: "Bob")).order("posts.id").pluck("posts.id") + + assert_equal [posts(:misc_by_bob).id, + posts(:other_by_bob).id], hello_by_bob + end + + test "merging order relations" do + posts_by_author_name = Post.limit(3).joins(:author). + merge(Author.order(:name)).pluck("authors.name") + + assert_equal ["Bob", "Bob", "David"], posts_by_author_name + + posts_by_author_name = Post.limit(3).joins(:author). + merge(Author.order("name")).pluck("authors.name") + + assert_equal ["Bob", "Bob", "David"], posts_by_author_name + end + + test "merging order relations (using a hash argument)" do + posts_by_author_name = Post.limit(4).joins(:author). + merge(Author.order(name: :desc)).pluck("authors.name") + + assert_equal ["Mary", "Mary", "Mary", "David"], posts_by_author_name + end + + test "relation merging (using a proc argument)" do + dev = Developer.where(name: "Jamis").first + + comment_1 = dev.comments.create!(body: "I'm Jamis", post: Post.first) + rating_1 = comment_1.ratings.create! + + comment_2 = dev.comments.create!(body: "I'm John", post: Post.first) + comment_2.ratings.create! + + assert_equal dev.ratings, [rating_1] + end +end diff --git a/activerecord/test/cases/relation/mutation_test.rb b/activerecord/test/cases/relation/mutation_test.rb new file mode 100644 index 0000000000..1428b3e132 --- /dev/null +++ b/activerecord/test/cases/relation/mutation_test.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +module ActiveRecord + class RelationMutationTest < ActiveRecord::TestCase + (Relation::MULTI_VALUE_METHODS - [:references, :extending, :order, :unscope, :select]).each do |method| + test "##{method}!" do + assert relation.public_send("#{method}!", :foo).equal?(relation) + assert_equal [:foo], relation.public_send("#{method}_values") + end + end + + test "#_select!" do + assert relation._select!(:foo).equal?(relation) + assert_equal [:foo], relation.select_values + end + + test "#order!" do + assert relation.order!("name ASC").equal?(relation) + assert_equal ["name ASC"], relation.order_values + end + + test "#order! with symbol prepends the table name" do + assert relation.order!(:name).equal?(relation) + node = relation.order_values.first + assert_predicate node, :ascending? + assert_equal :name, node.expr.name + assert_equal "posts", node.expr.relation.name + end + + test "#order! on non-string does not attempt regexp match for references" do + obj = Object.new + assert_not_called(obj, :=~) do + assert relation.order!(obj) + assert_equal [obj], relation.order_values + end + end + + test "#references!" do + assert relation.references!(:foo).equal?(relation) + assert_includes relation.references_values, "foo" + end + + test "extending!" do + mod, mod2 = Module.new, Module.new + + assert relation.extending!(mod).equal?(relation) + assert_equal [mod], relation.extending_values + assert relation.is_a?(mod) + + relation.extending!(mod2) + assert_equal [mod, mod2], relation.extending_values + end + + test "extending! with empty args" do + relation.extending! + assert_equal [], relation.extending_values + end + + (Relation::SINGLE_VALUE_METHODS - [:lock, :reordering, :reverse_order, :create_with, :skip_query_cache]).each do |method| + test "##{method}!" do + assert relation.public_send("#{method}!", :foo).equal?(relation) + assert_equal :foo, relation.public_send("#{method}_value") + end + end + + test "#from!" do + assert relation.from!("foo").equal?(relation) + assert_equal "foo", relation.from_clause.value + end + + test "#lock!" do + assert relation.lock!("foo").equal?(relation) + assert_equal "foo", relation.lock_value + end + + test "#reorder!" do + @relation = relation.order("foo") + + assert relation.reorder!("bar").equal?(relation) + assert_equal ["bar"], relation.order_values + assert relation.reordering_value + end + + test "#reorder! with symbol prepends the table name" do + assert relation.reorder!(:name).equal?(relation) + node = relation.order_values.first + + assert_predicate node, :ascending? + assert_equal :name, node.expr.name + assert_equal "posts", node.expr.relation.name + end + + test "reverse_order!" do + @relation = Post.order("title ASC, comments_count DESC") + + relation.reverse_order! + + assert_equal "title DESC", relation.order_values.first + assert_equal "comments_count ASC", relation.order_values.last + + relation.reverse_order! + + assert_equal "title ASC", relation.order_values.first + assert_equal "comments_count DESC", relation.order_values.last + end + + test "create_with!" do + assert relation.create_with!(foo: "bar").equal?(relation) + assert_equal({ foo: "bar" }, relation.create_with_value) + end + + test "merge!" do + assert relation.merge!(select: :foo).equal?(relation) + assert_equal [:foo], relation.select_values + end + + test "merge with a proc" do + assert_equal [:foo], relation.merge(-> { select(:foo) }).select_values + end + + test "none!" do + assert relation.none!.equal?(relation) + assert_equal [NullRelation], relation.extending_values + assert relation.is_a?(NullRelation) + end + + test "distinct!" do + relation.distinct! :foo + assert_equal :foo, relation.distinct_value + end + + test "skip_query_cache!" do + relation.skip_query_cache! + assert relation.skip_query_cache_value + end + + private + def relation + @relation ||= Relation.new(FakeKlass) + end + end +end diff --git a/activerecord/test/cases/relation/or_test.rb b/activerecord/test/cases/relation/or_test.rb new file mode 100644 index 0000000000..7e418f9c7d --- /dev/null +++ b/activerecord/test/cases/relation/or_test.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/categorization" +require "models/post" + +module ActiveRecord + class OrTest < ActiveRecord::TestCase + fixtures :posts + fixtures :authors, :author_addresses + + def test_or_with_relation + expected = Post.where("id = 1 or id = 2").to_a + assert_equal expected, Post.where("id = 1").or(Post.where("id = 2")).to_a + end + + def test_or_identity + expected = Post.where("id = 1").to_a + assert_equal expected, Post.where("id = 1").or(Post.where("id = 1")).to_a + end + + def test_or_with_null_left + expected = Post.where("id = 1").to_a + assert_equal expected, Post.none.or(Post.where("id = 1")).to_a + end + + def test_or_with_null_right + expected = Post.where("id = 1").to_a + assert_equal expected, Post.where("id = 1").or(Post.none).to_a + end + + def test_or_with_bind_params + assert_equal Post.find([1, 2]).sort_by(&:id), Post.where(id: 1).or(Post.where(id: 2)).sort_by(&:id) + end + + def test_or_with_null_both + expected = Post.none.to_a + assert_equal expected, Post.none.or(Post.none).to_a + end + + def test_or_without_left_where + expected = Post.all + assert_equal expected, Post.or(Post.where("id = 1")).to_a + end + + def test_or_without_right_where + expected = Post.all + assert_equal expected, Post.where("id = 1").or(Post.all).to_a + end + + def test_or_preserves_other_querying_methods + expected = Post.where("id = 1 or id = 2 or id = 3").order("body asc").to_a + partial = Post.order("body asc") + assert_equal expected, partial.where("id = 1").or(partial.where(id: [2, 3])).to_a + assert_equal expected, Post.order("body asc").where("id = 1").or(Post.order("body asc").where(id: [2, 3])).to_a + end + + def test_or_with_incompatible_relations + error = assert_raises ArgumentError do + Post.order("body asc").where("id = 1").or(Post.order("id desc").where(id: [2, 3])).to_a + end + + assert_equal "Relation passed to #or must be structurally compatible. Incompatible values: [:order]", error.message + end + + def test_or_with_unscope_where + expected = Post.where("id = 1 or id = 2") + partial = Post.where("id = 1 and id != 2") + assert_equal expected, partial.or(partial.unscope(:where).where("id = 2")).to_a + end + + def test_or_with_unscope_where_column + expected = Post.where("id = 1 or id = 2") + partial = Post.where(id: 1).where.not(id: 2) + assert_equal expected, partial.or(partial.unscope(where: :id).where("id = 2")).to_a + end + + def test_or_with_unscope_order + expected = Post.where("id = 1 or id = 2") + assert_equal expected, Post.order("body asc").where("id = 1").unscope(:order).or(Post.where("id = 2")).to_a + end + + def test_or_with_incompatible_unscope + error = assert_raises ArgumentError do + Post.order("body asc").where("id = 1").or(Post.order("body asc").where("id = 2").unscope(:order)).to_a + end + + assert_equal "Relation passed to #or must be structurally compatible. Incompatible values: [:order]", error.message + end + + def test_or_when_grouping + groups = Post.where("id < 10").group("body").select("body, COUNT(*) AS c") + expected = groups.having("COUNT(*) > 1 OR body like 'Such%'").to_a.map { |o| [o.body, o.c] } + assert_equal expected, groups.having("COUNT(*) > 1").or(groups.having("body like 'Such%'")).to_a.map { |o| [o.body, o.c] } + end + + def test_or_with_named_scope + expected = Post.where("id = 1 or body LIKE '\%a\%'").to_a + assert_equal expected, Post.where("id = 1").or(Post.containing_the_letter_a) + end + + def test_or_inside_named_scope + expected = Post.where("body LIKE '\%a\%' OR title LIKE ?", "%'%").order("id DESC").to_a + assert_equal expected, Post.order(id: :desc).typographically_interesting + end + + def test_or_on_loaded_relation + expected = Post.where("id = 1 or id = 2").to_a + p = Post.where("id = 1") + p.load + assert_equal true, p.loaded? + assert_equal expected, p.or(Post.where("id = 2")).to_a + end + + def test_or_with_non_relation_object_raises_error + assert_raises ArgumentError do + Post.where(id: [1, 2, 3]).or(title: "Rails") + end + end + + def test_or_with_references_inequality + joined = Post.includes(:author) + actual = joined.where(authors: { id: 1 }) + .or(joined.where(title: "I don't have any comments")) + expected = Author.find(1).posts + Post.where(title: "I don't have any comments") + assert_equal expected.sort_by(&:id), actual.sort_by(&:id) + end + end +end diff --git a/activerecord/test/cases/relation/predicate_builder_test.rb b/activerecord/test/cases/relation/predicate_builder_test.rb new file mode 100644 index 0000000000..b432330deb --- /dev/null +++ b/activerecord/test/cases/relation/predicate_builder_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" + +module ActiveRecord + class PredicateBuilderTest < ActiveRecord::TestCase + def test_registering_new_handlers + Topic.predicate_builder.register_handler(Regexp, proc do |column, value| + Arel::Nodes::InfixOperation.new("~", column, Arel.sql(value.source)) + end) + + assert_match %r{["`]topics["`]\.["`]title["`] ~ rails}i, Topic.where(title: /rails/).to_sql + ensure + Topic.reset_column_information + end + end +end diff --git a/activerecord/test/cases/relation/record_fetch_warning_test.rb b/activerecord/test/cases/relation/record_fetch_warning_test.rb new file mode 100644 index 0000000000..22d32d75bc --- /dev/null +++ b/activerecord/test/cases/relation/record_fetch_warning_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "active_record/relation/record_fetch_warning" + +module ActiveRecord + class RecordFetchWarningTest < ActiveRecord::TestCase + fixtures :posts + + def setup + @original_logger = ActiveRecord::Base.logger + @original_warn_on_records_fetched_greater_than = ActiveRecord::Base.warn_on_records_fetched_greater_than + @log = StringIO.new + end + + def teardown + ActiveRecord::Base.logger = @original_logger + ActiveRecord::Base.warn_on_records_fetched_greater_than = @original_warn_on_records_fetched_greater_than + end + + def test_warn_on_records_fetched_greater_than_allowed_limit + ActiveRecord::Base.logger = ActiveSupport::Logger.new(@log) + ActiveRecord::Base.logger.level = Logger::WARN + ActiveRecord::Base.warn_on_records_fetched_greater_than = 1 + + Post.all.to_a + + assert_match(/Query fetched/, @log.string) + end + + def test_does_not_warn_on_records_fetched_less_than_allowed_limit + ActiveRecord::Base.logger = ActiveSupport::Logger.new(@log) + ActiveRecord::Base.logger.level = Logger::WARN + ActiveRecord::Base.warn_on_records_fetched_greater_than = 100 + + Post.all.to_a + + assert_no_match(/Query fetched/, @log.string) + end + end +end diff --git a/activerecord/test/cases/relation/select_test.rb b/activerecord/test/cases/relation/select_test.rb new file mode 100644 index 0000000000..0577e6bfdb --- /dev/null +++ b/activerecord/test/cases/relation/select_test.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +module ActiveRecord + class SelectTest < ActiveRecord::TestCase + fixtures :posts + + def test_select_with_nil_agrument + expected = Post.select(:title).to_sql + assert_equal expected, Post.select(nil).select(:title).to_sql + end + end +end diff --git a/activerecord/test/cases/relation/where_chain_test.rb b/activerecord/test/cases/relation/where_chain_test.rb new file mode 100644 index 0000000000..a68eb2b446 --- /dev/null +++ b/activerecord/test/cases/relation/where_chain_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/comment" + +module ActiveRecord + class WhereChainTest < ActiveRecord::TestCase + fixtures :posts + + def setup + super + @name = "title" + end + + def test_not_inverts_where_clause + relation = Post.where.not(title: "hello") + expected_where_clause = Post.where(title: "hello").where_clause.invert + + assert_equal expected_where_clause, relation.where_clause + end + + def test_not_with_nil + assert_raise ArgumentError do + Post.where.not(nil) + end + end + + def test_association_not_eq + expected = Comment.arel_table[@name].not_eq(Arel::Nodes::BindParam.new(1)) + relation = Post.joins(:comments).where.not(comments: { title: "hello" }) + assert_equal(expected.to_sql, relation.where_clause.ast.to_sql) + end + + def test_not_eq_with_preceding_where + relation = Post.where(title: "hello").where.not(title: "world") + expected_where_clause = + Post.where(title: "hello").where_clause + + Post.where(title: "world").where_clause.invert + + assert_equal expected_where_clause, relation.where_clause + end + + def test_not_eq_with_succeeding_where + relation = Post.where.not(title: "hello").where(title: "world") + expected_where_clause = + Post.where(title: "hello").where_clause.invert + + Post.where(title: "world").where_clause + + assert_equal expected_where_clause, relation.where_clause + end + + def test_chaining_multiple + relation = Post.where.not(author_id: [1, 2]).where.not(title: "ruby on rails") + expected_where_clause = + Post.where(author_id: [1, 2]).where_clause.invert + + Post.where(title: "ruby on rails").where_clause.invert + + assert_equal expected_where_clause, relation.where_clause + end + + def test_rewhere_with_one_condition + relation = Post.where(title: "hello").where(title: "world").rewhere(title: "alone") + expected = Post.where(title: "alone") + + assert_equal expected.where_clause, relation.where_clause + end + + def test_rewhere_with_multiple_overwriting_conditions + relation = Post.where(title: "hello").where(body: "world").rewhere(title: "alone", body: "again") + expected = Post.where(title: "alone", body: "again") + + assert_equal expected.where_clause, relation.where_clause + end + + def test_rewhere_with_one_overwriting_condition_and_one_unrelated + relation = Post.where(title: "hello").where(body: "world").rewhere(title: "alone") + expected = Post.where(body: "world", title: "alone") + + assert_equal expected.where_clause, relation.where_clause + end + + def test_rewhere_with_range + relation = Post.where(comments_count: 1..3).rewhere(comments_count: 3..5) + + assert_equal Post.where(comments_count: 3..5), relation + end + + def test_rewhere_with_infinite_upper_bound_range + relation = Post.where(comments_count: 1..Float::INFINITY).rewhere(comments_count: 3..5) + + assert_equal Post.where(comments_count: 3..5), relation + end + + def test_rewhere_with_infinite_lower_bound_range + relation = Post.where(comments_count: -Float::INFINITY..1).rewhere(comments_count: 3..5) + + assert_equal Post.where(comments_count: 3..5), relation + end + + def test_rewhere_with_infinite_range + relation = Post.where(comments_count: -Float::INFINITY..Float::INFINITY).rewhere(comments_count: 3..5) + + assert_equal Post.where(comments_count: 3..5), relation + end + end +end diff --git a/activerecord/test/cases/relation/where_clause_test.rb b/activerecord/test/cases/relation/where_clause_test.rb new file mode 100644 index 0000000000..8703d238a0 --- /dev/null +++ b/activerecord/test/cases/relation/where_clause_test.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +require "cases/helper" + +class ActiveRecord::Relation + class WhereClauseTest < ActiveRecord::TestCase + test "+ combines two where clauses" do + first_clause = WhereClause.new([table["id"].eq(bind_param(1))]) + second_clause = WhereClause.new([table["name"].eq(bind_param("Sean"))]) + combined = WhereClause.new( + [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))], + ) + + assert_equal combined, first_clause + second_clause + end + + test "+ is associative, but not commutative" do + a = WhereClause.new(["a"]) + b = WhereClause.new(["b"]) + c = WhereClause.new(["c"]) + + assert_equal a + (b + c), (a + b) + c + assert_not_equal a + b, b + a + end + + test "an empty where clause is the identity value for +" do + clause = WhereClause.new([table["id"].eq(bind_param(1))]) + + assert_equal clause, clause + WhereClause.empty + end + + test "merge combines two where clauses" do + a = WhereClause.new([table["id"].eq(1)]) + b = WhereClause.new([table["name"].eq("Sean")]) + expected = WhereClause.new([table["id"].eq(1), table["name"].eq("Sean")]) + + assert_equal expected, a.merge(b) + end + + test "merge keeps the right side, when two equality clauses reference the same column" do + a = WhereClause.new([table["id"].eq(1), table["name"].eq("Sean")]) + b = WhereClause.new([table["name"].eq("Jim")]) + expected = WhereClause.new([table["id"].eq(1), table["name"].eq("Jim")]) + + assert_equal expected, a.merge(b) + end + + test "merge removes bind parameters matching overlapping equality clauses" do + a = WhereClause.new( + [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))], + ) + b = WhereClause.new( + [table["name"].eq(bind_param("Jim"))], + ) + expected = WhereClause.new( + [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Jim"))], + ) + + assert_equal expected, a.merge(b) + end + + test "merge allows for columns with the same name from different tables" do + table2 = Arel::Table.new("table2") + a = WhereClause.new( + [table["id"].eq(bind_param(1)), table2["id"].eq(bind_param(2))], + ) + b = WhereClause.new( + [table["id"].eq(bind_param(3))], + ) + expected = WhereClause.new( + [table2["id"].eq(bind_param(2)), table["id"].eq(bind_param(3))], + ) + + assert_equal expected, a.merge(b) + end + + test "a clause knows if it is empty" do + assert_empty WhereClause.empty + assert_not_empty WhereClause.new(["anything"]) + end + + test "invert cannot handle nil" do + where_clause = WhereClause.new([nil]) + + assert_raises ArgumentError do + where_clause.invert + end + end + + test "invert replaces each part of the predicate with its inverse" do + random_object = Object.new + original = WhereClause.new([ + table["id"].in([1, 2, 3]), + table["id"].eq(1), + "sql literal", + random_object + ]) + expected = WhereClause.new([ + table["id"].not_in([1, 2, 3]), + table["id"].not_eq(1), + Arel::Nodes::Not.new(Arel::Nodes::SqlLiteral.new("sql literal")), + Arel::Nodes::Not.new(random_object) + ]) + + assert_equal expected, original.invert + end + + test "except removes binary predicates referencing a given column" do + where_clause = WhereClause.new([ + table["id"].in([1, 2, 3]), + table["name"].eq(bind_param("Sean")), + table["age"].gteq(bind_param(30)), + ]) + expected = WhereClause.new([table["age"].gteq(bind_param(30))]) + + assert_equal expected, where_clause.except("id", "name") + end + + test "except jumps over unhandled binds (like with OR) correctly" do + wcs = (0..9).map do |i| + WhereClause.new([table["id#{i}"].eq(bind_param(i))]) + end + + wc = wcs[0] + wcs[1] + wcs[2].or(wcs[3]) + wcs[4] + wcs[5] + wcs[6].or(wcs[7]) + wcs[8] + wcs[9] + + expected = wcs[0] + wcs[2].or(wcs[3]) + wcs[5] + wcs[6].or(wcs[7]) + wcs[9] + actual = wc.except("id1", "id2", "id4", "id7", "id8") + + assert_equal expected, actual + end + + test "ast groups its predicates with AND" do + predicates = [ + table["id"].in([1, 2, 3]), + table["name"].eq(bind_param(nil)), + ] + where_clause = WhereClause.new(predicates) + expected = Arel::Nodes::And.new(predicates) + + assert_equal expected, where_clause.ast + end + + test "ast wraps any SQL literals in parenthesis" do + random_object = Object.new + where_clause = WhereClause.new([ + table["id"].in([1, 2, 3]), + "foo = bar", + random_object, + ]) + expected = Arel::Nodes::And.new([ + table["id"].in([1, 2, 3]), + Arel::Nodes::Grouping.new(Arel.sql("foo = bar")), + random_object, + ]) + + assert_equal expected, where_clause.ast + end + + test "ast removes any empty strings" do + where_clause = WhereClause.new([table["id"].in([1, 2, 3])]) + where_clause_with_empty = WhereClause.new([table["id"].in([1, 2, 3]), ""]) + + assert_equal where_clause.ast, where_clause_with_empty.ast + end + + test "or joins the two clauses using OR" do + where_clause = WhereClause.new([table["id"].eq(bind_param(1))]) + other_clause = WhereClause.new([table["name"].eq(bind_param("Sean"))]) + expected_ast = + Arel::Nodes::Grouping.new( + Arel::Nodes::Or.new(table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))) + ) + + assert_equal expected_ast.to_sql, where_clause.or(other_clause).ast.to_sql + end + + test "or returns an empty where clause when either side is empty" do + where_clause = WhereClause.new([table["id"].eq(bind_param(1))]) + + assert_equal WhereClause.empty, where_clause.or(WhereClause.empty) + assert_equal WhereClause.empty, WhereClause.empty.or(where_clause) + end + + test "or places common conditions before the OR" do + a = WhereClause.new( + [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))], + ) + b = WhereClause.new( + [table["id"].eq(bind_param(1)), table["hair_color"].eq(bind_param("black"))], + ) + + common = WhereClause.new( + [table["id"].eq(bind_param(1))], + ) + + or_clause = WhereClause.new([table["name"].eq(bind_param("Sean"))]) + .or(WhereClause.new([table["hair_color"].eq(bind_param("black"))])) + + assert_equal common + or_clause, a.or(b) + end + + test "or can detect identical or as being a common condition" do + common_or = WhereClause.new([table["name"].eq(bind_param("Sean"))]) + .or(WhereClause.new([table["hair_color"].eq(bind_param("black"))])) + + a = common_or + WhereClause.new([table["id"].eq(bind_param(1))]) + b = common_or + WhereClause.new([table["foo"].eq(bind_param("bar"))]) + + new_or = WhereClause.new([table["id"].eq(bind_param(1))]) + .or(WhereClause.new([table["foo"].eq(bind_param("bar"))])) + + assert_equal common_or + new_or, a.or(b) + end + + test "or will use only common conditions if one side only has common conditions" do + only_common = WhereClause.new([ + table["id"].eq(bind_param(1)), + "foo = bar", + ]) + + common_with_extra = WhereClause.new([ + table["id"].eq(bind_param(1)), + "foo = bar", + table["extra"].eq(bind_param("pluto")), + ]) + + assert_equal only_common, only_common.or(common_with_extra) + assert_equal only_common, common_with_extra.or(only_common) + end + + private + + def table + Arel::Table.new("table") + end + + def bind_param(value) + Arel::Nodes::BindParam.new(value) + end + end +end diff --git a/activerecord/test/cases/relation/where_test.rb b/activerecord/test/cases/relation/where_test.rb new file mode 100644 index 0000000000..99797528b2 --- /dev/null +++ b/activerecord/test/cases/relation/where_test.rb @@ -0,0 +1,366 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/binary" +require "models/cake_designer" +require "models/car" +require "models/chef" +require "models/post" +require "models/comment" +require "models/edge" +require "models/essay" +require "models/price_estimate" +require "models/topic" +require "models/treasure" +require "models/vertex" + +module ActiveRecord + class WhereTest < ActiveRecord::TestCase + fixtures :posts, :edges, :authors, :author_addresses, :binaries, :essays, :cars, :treasures, :price_estimates, :topics + + def test_where_copies_bind_params + author = authors(:david) + posts = author.posts.where("posts.id != 1") + joined = Post.where(id: posts) + + assert_operator joined.length, :>, 0 + + joined.each { |post| + assert_equal author, post.author + assert_not_equal 1, post.id + } + end + + def test_where_copies_bind_params_in_the_right_order + author = authors(:david) + posts = author.posts.where.not(id: 1) + joined = Post.where(id: posts, title: posts.first.title) + + assert_equal joined, [posts.first] + end + + def test_where_copies_arel_bind_params + chef = Chef.create! + CakeDesigner.create!(chef: chef) + + cake_designers = CakeDesigner.joins(:chef).where(chefs: { id: chef.id }) + chefs = Chef.where(employable: cake_designers) + + assert_equal [chef], chefs.to_a + end + + def test_where_with_casted_value_is_nil + assert_equal 4, Topic.where(last_read: "").count + end + + def test_rewhere_on_root + assert_equal posts(:welcome), Post.rewhere(title: "Welcome to the weblog").first + end + + def test_belongs_to_shallow_where + author = Author.new + author.id = 1 + + assert_equal Post.where(author_id: 1).to_sql, Post.where(author: author).to_sql + end + + def test_belongs_to_nil_where + assert_equal Post.where(author_id: nil).to_sql, Post.where(author: nil).to_sql + end + + def test_belongs_to_array_value_where + assert_equal Post.where(author_id: [1, 2]).to_sql, Post.where(author: [1, 2]).to_sql + end + + def test_belongs_to_nested_relation_where + expected = Post.where(author_id: Author.where(id: [1, 2])).to_sql + actual = Post.where(author: Author.where(id: [1, 2])).to_sql + + assert_equal expected, actual + end + + def test_belongs_to_nested_where + parent = Comment.new + parent.id = 1 + + expected = Post.where(comments: { parent_id: 1 }).joins(:comments) + actual = Post.where(comments: { parent: parent }).joins(:comments) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_belongs_to_nested_where_with_relation + author = authors(:david) + + expected = Author.where(id: author).joins(:posts) + actual = Author.where(posts: { author_id: Author.where(id: author.id) }).joins(:posts) + + assert_equal expected.to_a, actual.to_a + end + + def test_polymorphic_shallow_where + treasure = Treasure.new + treasure.id = 1 + + expected = PriceEstimate.where(estimate_of_type: "Treasure", estimate_of_id: 1) + actual = PriceEstimate.where(estimate_of: treasure) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_polymorphic_shallow_where_not + treasure = treasures(:sapphire) + + expected = [price_estimates(:diamond), price_estimates(:honda)] + actual = PriceEstimate.where.not(estimate_of: treasure) + + assert_equal expected.sort_by(&:id), actual.sort_by(&:id) + end + + def test_polymorphic_nested_array_where + treasure = Treasure.new + treasure.id = 1 + hidden = HiddenTreasure.new + hidden.id = 2 + + expected = PriceEstimate.where(estimate_of_type: "Treasure", estimate_of_id: [treasure, hidden]) + actual = PriceEstimate.where(estimate_of: [treasure, hidden]) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_polymorphic_nested_array_where_not + treasure = treasures(:diamond) + car = cars(:honda) + + expected = [price_estimates(:sapphire_1), price_estimates(:sapphire_2)] + actual = PriceEstimate.where.not(estimate_of: [treasure, car]) + + assert_equal expected.sort_by(&:id), actual.sort_by(&:id) + end + + def test_polymorphic_array_where_multiple_types + treasure_1 = treasures(:diamond) + treasure_2 = treasures(:sapphire) + car = cars(:honda) + + expected = [price_estimates(:diamond), price_estimates(:sapphire_1), price_estimates(:sapphire_2), price_estimates(:honda)].sort + actual = PriceEstimate.where(estimate_of: [treasure_1, treasure_2, car]).to_a.sort + + assert_equal expected, actual + end + + def test_polymorphic_nested_relation_where + expected = PriceEstimate.where(estimate_of_type: "Treasure", estimate_of_id: Treasure.where(id: [1, 2])) + actual = PriceEstimate.where(estimate_of: Treasure.where(id: [1, 2])) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_polymorphic_sti_shallow_where + treasure = HiddenTreasure.new + treasure.id = 1 + + expected = PriceEstimate.where(estimate_of_type: "Treasure", estimate_of_id: 1) + actual = PriceEstimate.where(estimate_of: treasure) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_polymorphic_nested_where + thing = Post.new + thing.id = 1 + + expected = Treasure.where(price_estimates: { thing_type: "Post", thing_id: 1 }).joins(:price_estimates) + actual = Treasure.where(price_estimates: { thing: thing }).joins(:price_estimates) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_polymorphic_sti_nested_where + treasure = HiddenTreasure.new + treasure.id = 1 + + expected = Treasure.where(price_estimates: { estimate_of_type: "Treasure", estimate_of_id: 1 }).joins(:price_estimates) + actual = Treasure.where(price_estimates: { estimate_of: treasure }).joins(:price_estimates) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_decorated_polymorphic_where + treasure_decorator = Struct.new(:model) do + def self.method_missing(method, *args, &block) + Treasure.send(method, *args, &block) + end + + def is_a?(klass) + model.is_a?(klass) + end + + def method_missing(method, *args, &block) + model.send(method, *args, &block) + end + end + + treasure = Treasure.new + treasure.id = 1 + decorated_treasure = treasure_decorator.new(treasure) + + expected = PriceEstimate.where(estimate_of_type: "Treasure", estimate_of_id: 1) + actual = PriceEstimate.where(estimate_of: decorated_treasure) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_aliased_attribute + expected = Topic.where(heading: "The First Topic") + actual = Topic.where(title: "The First Topic") + + assert_equal expected.to_sql, actual.to_sql + end + + def test_where_error + assert_nothing_raised do + Post.where(id: { "posts.author_id" => 10 }).first + end + end + + def test_where_with_table_name + post = Post.first + assert_equal post, Post.where(posts: { "id" => post.id }).first + end + + def test_where_with_table_name_and_empty_hash + assert_equal 0, Post.where(posts: {}).count + end + + def test_where_with_table_name_and_empty_array + assert_equal 0, Post.where(id: []).count + end + + def test_where_with_empty_hash_and_no_foreign_key + assert_equal 0, Edge.where(sink: {}).count + end + + def test_where_with_blank_conditions + [[], {}, nil, ""].each do |blank| + assert_equal 4, Edge.where(blank).order("sink_id").to_a.size + end + end + + def test_where_with_integer_for_string_column + count = Post.where(title: 0).count + assert_equal 0, count + end + + def test_where_with_float_for_string_column + count = Post.where(title: 0.0).count + assert_equal 0, count + end + + def test_where_with_boolean_for_string_column + count = Post.where(title: false).count + assert_equal 0, count + end + + def test_where_with_decimal_for_string_column + count = Post.where(title: BigDecimal(0)).count + assert_equal 0, count + end + + def test_where_with_duration_for_string_column + count = Post.where(title: 0.seconds).count + assert_equal 0, count + end + + def test_where_with_integer_for_binary_column + count = Binary.where(data: 0).count + assert_equal 0, count + end + + def test_where_on_association_with_custom_primary_key + author = authors(:david) + essay = Essay.where(writer: author).first + + assert_equal essays(:david_modest_proposal), essay + end + + def test_where_on_association_with_custom_primary_key_with_relation + author = authors(:david) + essay = Essay.where(writer: Author.where(id: author.id)).first + + assert_equal essays(:david_modest_proposal), essay + end + + def test_where_on_association_with_relation_performs_subselect_not_two_queries + author = authors(:david) + + assert_queries(1) do + Essay.where(writer: Author.where(id: author.id)).to_a + end + end + + def test_where_on_association_with_custom_primary_key_with_array_of_base + author = authors(:david) + essay = Essay.where(writer: [author]).first + + assert_equal essays(:david_modest_proposal), essay + end + + def test_where_on_association_with_custom_primary_key_with_array_of_ids + essay = Essay.where(writer: ["David"]).first + + assert_equal essays(:david_modest_proposal), essay + end + + def test_where_with_relation_on_has_many_association + essay = essays(:david_modest_proposal) + author = Author.where(essays: Essay.where(id: essay.id)).first + + assert_equal authors(:david), author + end + + def test_where_with_relation_on_has_one_association + author = authors(:david) + author_address = AuthorAddress.where(author: Author.where(id: author.id)).first + assert_equal author_addresses(:david_address), author_address + end + + + def test_where_on_association_with_select_relation + essay = Essay.where(author: Author.where(name: "David").select(:name)).take + assert_equal essays(:david_modest_proposal), essay + end + + def test_where_with_strong_parameters + protected_params = Class.new do + attr_reader :permitted + alias :permitted? :permitted + + def initialize(parameters) + @parameters = parameters + @permitted = false + end + + def to_h + @parameters + end + + def permit! + @permitted = true + self + end + end + + author = authors(:david) + params = protected_params.new(name: author.name) + assert_raises(ActiveModel::ForbiddenAttributesError) { Author.where(params) } + assert_equal author, Author.where(params.permit!).first + end + + def test_where_with_unsupported_arguments + assert_raises(ArgumentError) { Author.where(42) } + end + end +end |