diff options
19 files changed, 168 insertions, 87 deletions
diff --git a/actionview/lib/action_view/helpers/translation_helper.rb b/actionview/lib/action_view/helpers/translation_helper.rb index 67c86592b9..8289c806ee 100644 --- a/actionview/lib/action_view/helpers/translation_helper.rb +++ b/actionview/lib/action_view/helpers/translation_helper.rb @@ -138,7 +138,7 @@ module ActionView end def html_safe_translation_key?(key) - /([_.]|\b)html\z/.match?(key.to_s) + /(?:_|\b)html\z/.match?(key.to_s) end end end diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index ce1f1102d5..0cfaf39281 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -382,6 +382,12 @@ *Federico Martinez* +* Add `reselect` method. This is a short-hand for `unscope(:select).select(fields)`. + + Fixes #27340. + + *Willian Gustavo Veiga* + * Add basic API for connection switching to support multiple databases. 1) Adds a `connects_to` method for models to connect to multiple databases. Example: diff --git a/activerecord/lib/active_record/core.rb b/activerecord/lib/active_record/core.rb index 18cfac1f2f..eb4b48bc37 100644 --- a/activerecord/lib/active_record/core.rb +++ b/activerecord/lib/active_record/core.rb @@ -161,7 +161,7 @@ module ActiveRecord return super if block_given? || primary_key.nil? || scope_attributes? || - columns_hash.include?(inheritance_column) + columns_hash.key?(inheritance_column) && !base_class? id = ids.first diff --git a/activerecord/lib/active_record/relation/delegation.rb b/activerecord/lib/active_record/relation/delegation.rb index f7c3b3783f..7a53a9d1c7 100644 --- a/activerecord/lib/active_record/relation/delegation.rb +++ b/activerecord/lib/active_record/relation/delegation.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "mutex_m" + module ActiveRecord module Delegation # :nodoc: module DelegateCache # :nodoc: @@ -31,6 +33,10 @@ module ActiveRecord super end + def generate_relation_method(method) + generated_relation_methods.generate_method(method) + end + protected def include_relation_methods(delegate) superclass.include_relation_methods(delegate) unless base_class? @@ -39,27 +45,32 @@ module ActiveRecord private def generated_relation_methods - @generated_relation_methods ||= Module.new.tap do |mod| - mod_name = "GeneratedRelationMethods" - const_set mod_name, mod - private_constant mod_name - end + @generated_relation_methods ||= GeneratedRelationMethods.new end + end + + class GeneratedRelationMethods < Module # :nodoc: + include Mutex_m + + def generate_method(method) + synchronize do + return if method_defined?(method) - def generate_relation_method(method) if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method) - generated_relation_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 + module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{method}(*args, &block) scoping { klass.#{method}(*args, &block) } end RUBY else - generated_relation_methods.define_method(method) do |*args, &block| + define_method(method) do |*args, &block| scoping { klass.public_send(method, *args, &block) } end end end + end end + private_constant :GeneratedRelationMethods extend ActiveSupport::Concern @@ -78,39 +89,17 @@ module ActiveRecord module ClassSpecificRelation # :nodoc: extend ActiveSupport::Concern - included do - @delegation_mutex = Mutex.new - end - module ClassMethods # :nodoc: def name superclass.name end - - def delegate_to_scoped_klass(method) - @delegation_mutex.synchronize do - return if method_defined?(method) - - if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method) - module_eval <<-RUBY, __FILE__, __LINE__ + 1 - def #{method}(*args, &block) - scoping { @klass.#{method}(*args, &block) } - end - RUBY - else - define_method method do |*args, &block| - scoping { @klass.public_send(method, *args, &block) } - end - end - end - end end private def method_missing(method, *args, &block) if @klass.respond_to?(method) - self.class.delegate_to_scoped_klass(method) + @klass.generate_relation_method(method) scoping { @klass.public_send(method, *args, &block) } else super diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index 91cfc4e849..74f323a278 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -7,8 +7,8 @@ module ActiveRecord ONE_AS_ONE = "1 AS one" # Find by id - This can either be a specific id (1), a list of ids (1, 5, 6), or an array of ids ([5, 6, 10]). - # If one or more records can not be found for the requested ids, then RecordNotFound will be raised. If the primary key - # is an integer, find by id coerces its arguments using +to_i+. + # If one or more records can not be found for the requested ids, then ActiveRecord::RecordNotFound will be raised. + # If the primary key is an integer, find by id coerces its arguments by using +to_i+. # # Person.find(1) # returns the object for ID = 1 # Person.find("1") # returns the object for ID = 1 diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index f69b85af66..24a50db619 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -240,6 +240,27 @@ module ActiveRecord self end + # Allows you to change a previously set select statement. + # + # Post.select(:title, :body) + # # SELECT `posts.title`, `posts.body` FROM `posts` + # + # Post.select(:title, :body).reselect(:created_at) + # # SELECT `posts.created_at` FROM `posts` + # + # This is short-hand for <tt>unscope(:select).select(fields)</tt>. + # Note that we're unscoping the entire select statement. + def reselect(*args) + check_if_method_has_arguments!(:reselect, args) + spawn.reselect!(*args) + end + + # Same as #reselect but operates on relation in-place instead of copying. + def reselect!(*args) # :nodoc: + self.select_values = args + self + end + # Allows to specify a group attribute: # # User.group(:name) @@ -1070,14 +1091,17 @@ module ActiveRecord field = klass.attribute_alias(field) if klass.attribute_alias?(field) from = from_clause.name || from_clause.value - if klass.columns_hash.key?(field) && - (!from || from == table.name || from == connection.quote_table_name(table.name)) + if klass.columns_hash.key?(field) && (!from || table_name_matches?(from)) arel_attribute(field) else yield end end + def table_name_matches?(from) + /(?:\A|(?<!FROM)\s)(?:\b#{table.name}\b|#{connection.quote_table_name(table.name)})(?!\.)/i.match?(from.to_s) + end + def reverse_sql_order(order_query) if order_query.empty? return [arel_attribute(primary_key).desc] if primary_key diff --git a/activerecord/lib/active_record/scoping/default.rb b/activerecord/lib/active_record/scoping/default.rb index 8c612df27a..de75fbe127 100644 --- a/activerecord/lib/active_record/scoping/default.rb +++ b/activerecord/lib/active_record/scoping/default.rb @@ -100,7 +100,7 @@ module ActiveRecord self.default_scopes += [scope] end - def build_default_scope(base_rel = nil) + def build_default_scope(relation = relation()) return if abstract_class? if default_scope_override.nil? @@ -111,15 +111,14 @@ module ActiveRecord # The user has defined their own default scope method, so call that evaluate_default_scope do if scope = default_scope - (base_rel ||= relation).merge!(scope) + relation.merge!(scope) end end elsif default_scopes.any? - base_rel ||= relation evaluate_default_scope do - default_scopes.inject(base_rel) do |default_scope, scope| + default_scopes.inject(relation) do |default_scope, scope| scope = scope.respond_to?(:to_proc) ? scope : scope.method(:call) - default_scope.merge!(base_rel.instance_exec(&scope)) + default_scope.instance_exec(&scope) || default_scope end end end diff --git a/activerecord/test/cases/associations/has_many_through_associations_test.rb b/activerecord/test/cases/associations/has_many_through_associations_test.rb index 6f23a832ef..67e013c6e0 100644 --- a/activerecord/test/cases/associations/has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -56,11 +56,11 @@ class HasManyThroughAssociationsTest < ActiveRecord::TestCase end def test_preload_with_nested_association - posts = Post.preload(:author, :author_favorites).to_a + posts = Post.preload(:author, :author_favorites_with_scope).to_a assert_no_queries do posts.each(&:author) - posts.each(&:author_favorites) + posts.each(&:author_favorites_with_scope) end end diff --git a/activerecord/test/cases/bind_parameter_test.rb b/activerecord/test/cases/bind_parameter_test.rb index b89f054821..03cc0dffbc 100644 --- a/activerecord/test/cases/bind_parameter_test.rb +++ b/activerecord/test/cases/bind_parameter_test.rb @@ -54,17 +54,36 @@ if ActiveRecord::Base.connection.prepared_statements @connection.disable_query_cache! end + def test_statement_cache_with_find + @connection.clear_cache! + + assert_equal 1, Topic.find(1).id + assert_raises(RecordNotFound) { SillyReply.find(2) } + + topic_sql = cached_statement(Topic, Topic.primary_key) + assert_includes statement_cache, to_sql_key(topic_sql) + + e = assert_raise { cached_statement(SillyReply, SillyReply.primary_key) } + assert_equal "SillyReply has no cached statement by \"id\"", e.message + + replies = SillyReply.where(id: 2).limit(1) + assert_includes statement_cache, to_sql_key(replies.arel) + end + def test_statement_cache_with_find_by @connection.clear_cache! assert_equal 1, Topic.find_by!(id: 1).id - assert_equal 2, Reply.find_by!(id: 2).id + assert_raises(RecordNotFound) { SillyReply.find_by!(id: 2) } topic_sql = cached_statement(Topic, [:id]) assert_includes statement_cache, to_sql_key(topic_sql) - e = assert_raise { cached_statement(Reply, [:id]) } - assert_equal "Reply has no cached statement by [:id]", e.message + e = assert_raise { cached_statement(SillyReply, [:id]) } + assert_equal "SillyReply has no cached statement by [:id]", e.message + + replies = SillyReply.where(id: 2).limit(1) + assert_includes statement_cache, to_sql_key(replies.arel) end def test_statement_cache_with_in_clause diff --git a/activerecord/test/cases/relation/select_test.rb b/activerecord/test/cases/relation/select_test.rb index dec8a6925d..32e8f473ff 100644 --- a/activerecord/test/cases/relation/select_test.rb +++ b/activerecord/test/cases/relation/select_test.rb @@ -11,5 +11,10 @@ module ActiveRecord expected = Post.select(:title).to_sql assert_equal expected, Post.select(nil).select(:title).to_sql end + + def test_reselect + expected = Post.select(:title).to_sql + assert_equal expected, Post.select(:title, :body).reselect(:title).to_sql + end end end diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index a7f09e6de0..85719cb8d0 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -182,27 +182,27 @@ class RelationTest < ActiveRecord::TestCase end end - def test_select_with_original_table_name_in_from + def test_select_with_from_includes_original_table_name relation = Comment.joins(:post).select(:id).order(:id) - subquery = Comment.from(Comment.table_name).joins(:post).select(:id).order(:id) + subquery = Comment.from("#{Comment.table_name} /*! USE INDEX (PRIMARY) */").joins(:post).select(:id).order(:id) assert_equal relation.map(&:id), subquery.map(&:id) end - def test_pluck_with_original_table_name_in_from + def test_pluck_with_from_includes_original_table_name relation = Comment.joins(:post).order(:id) - subquery = Comment.from(Comment.table_name).joins(:post).order(:id) + subquery = Comment.from("#{Comment.table_name} /*! USE INDEX (PRIMARY) */").joins(:post).order(:id) assert_equal relation.pluck(:id), subquery.pluck(:id) end - def test_select_with_quoted_original_table_name_in_from + def test_select_with_from_includes_quoted_original_table_name relation = Comment.joins(:post).select(:id).order(:id) - subquery = Comment.from(Comment.quoted_table_name).joins(:post).select(:id).order(:id) + subquery = Comment.from("#{Comment.quoted_table_name} /*! USE INDEX (PRIMARY) */").joins(:post).select(:id).order(:id) assert_equal relation.map(&:id), subquery.map(&:id) end - def test_pluck_with_quoted_original_table_name_in_from + def test_pluck_with_from_includes_quoted_original_table_name relation = Comment.joins(:post).order(:id) - subquery = Comment.from(Comment.quoted_table_name).joins(:post).order(:id) + subquery = Comment.from("#{Comment.quoted_table_name} /*! USE INDEX (PRIMARY) */").joins(:post).order(:id) assert_equal relation.pluck(:id), subquery.pluck(:id) end @@ -221,13 +221,25 @@ class RelationTest < ActiveRecord::TestCase def test_select_with_subquery_in_from_does_not_use_original_table_name relation = Comment.group(:type).select("COUNT(post_id) AS post_count, type") - subquery = Comment.from(relation).select("type", "post_count") + subquery = Comment.from(relation, "grouped_#{Comment.table_name}").select("type", "post_count") assert_equal(relation.map(&:post_count).sort, subquery.map(&:post_count).sort) end def test_group_with_subquery_in_from_does_not_use_original_table_name relation = Comment.group(:type).select("COUNT(post_id) AS post_count,type") - subquery = Comment.from(relation).group("type").average("post_count") + subquery = Comment.from(relation, "grouped_#{Comment.table_name}").group("type").average("post_count") + assert_equal(relation.map(&:post_count).sort, subquery.values.sort) + end + + def test_select_with_subquery_string_in_from_does_not_use_original_table_name + relation = Comment.group(:type).select("COUNT(post_id) AS post_count, type") + subquery = Comment.from("(#{relation.to_sql}) #{Comment.table_name}_grouped").select("type", "post_count") + assert_equal(relation.map(&:post_count).sort, subquery.map(&:post_count).sort) + end + + def test_group_with_subquery_string_in_from_does_not_use_original_table_name + relation = Comment.group(:type).select("COUNT(post_id) AS post_count,type") + subquery = Comment.from("(#{relation.to_sql}) #{Comment.table_name}_grouped").group("type").average("post_count") assert_equal(relation.map(&:post_count).sort, subquery.values.sort) end diff --git a/activerecord/test/cases/scoping/default_scoping_test.rb b/activerecord/test/cases/scoping/default_scoping_test.rb index 6281712df6..e7bdab58c6 100644 --- a/activerecord/test/cases/scoping/default_scoping_test.rb +++ b/activerecord/test/cases/scoping/default_scoping_test.rb @@ -408,18 +408,18 @@ class DefaultScopingTest < ActiveRecord::TestCase end def test_joins_not_affected_by_scope_other_than_default_or_unscoped - without_scope_on_post = Comment.joins(:post).to_a + without_scope_on_post = Comment.joins(:post).sort_by(&:id) with_scope_on_post = nil Post.where(id: [1, 5, 6]).scoping do - with_scope_on_post = Comment.joins(:post).to_a + with_scope_on_post = Comment.joins(:post).sort_by(&:id) end - assert_equal with_scope_on_post, without_scope_on_post + assert_equal without_scope_on_post, with_scope_on_post end def test_unscoped_with_joins_should_not_have_default_scope - assert_equal SpecialPostWithDefaultScope.unscoped { Comment.joins(:special_post_with_default_scope).to_a }, - Comment.joins(:post).to_a + assert_equal Comment.joins(:post).sort_by(&:id), + SpecialPostWithDefaultScope.unscoped { Comment.joins(:special_post_with_default_scope).sort_by(&:id) } end def test_sti_association_with_unscoped_not_affected_by_default_scope diff --git a/activerecord/test/cases/serialized_attribute_test.rb b/activerecord/test/cases/serialized_attribute_test.rb index fa136fe8da..ecf81b2042 100644 --- a/activerecord/test/cases/serialized_attribute_test.rb +++ b/activerecord/test/cases/serialized_attribute_test.rb @@ -1,28 +1,29 @@ # frozen_string_literal: true require "cases/helper" -require "models/topic" -require "models/reply" require "models/person" require "models/traffic_light" require "models/post" -require "bcrypt" class SerializedAttributeTest < ActiveRecord::TestCase fixtures :topics, :posts MyObject = Struct.new :attribute1, :attribute2 - # NOTE: Use a duplicate of Topic so attribute - # changes don't bleed into other tests - Topic = ::Topic.dup + class Topic < ActiveRecord::Base + serialize :content + end + + class ImportantTopic < Topic + serialize :important, Hash + end teardown do Topic.serialize("content") end def test_serialize_does_not_eagerly_load_columns - reset_column_information_of(Topic) + Topic.reset_column_information assert_no_queries do Topic.serialize(:content) end @@ -53,10 +54,10 @@ class SerializedAttributeTest < ActiveRecord::TestCase def test_serialized_attributes_from_database_on_subclass Topic.serialize :content, Hash - t = Reply.new(content: { foo: :bar }) + t = ImportantTopic.new(content: { foo: :bar }) assert_equal({ foo: :bar }, t.content) t.save! - t = Reply.last + t = ImportantTopic.last assert_equal({ foo: :bar }, t.content) end @@ -371,14 +372,13 @@ class SerializedAttributeTest < ActiveRecord::TestCase end def test_serialized_attribute_works_under_concurrent_initial_access - model = ::Topic.dup + model = Class.new(Topic) - topic = model.last + topic = model.create! topic.update group: "1" model.serialize :group, JSON - - reset_column_information_of(model) + model.reset_column_information # This isn't strictly necessary for the test, but a little bit of # knowledge of internals allows us to make failures far more likely. @@ -398,12 +398,4 @@ class SerializedAttributeTest < ActiveRecord::TestCase # raw string ("1"), or raise an exception. assert_equal [1] * threads.size, threads.map(&:value) end - - private - - def reset_column_information_of(topic_class) - topic_class.reset_column_information - # reset original topic to undefine attribute methods - ::Topic.reset_column_information - end end diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index 3eb8a3a0fa..67be59a1fe 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -217,6 +217,13 @@ class AuthorAddress < ActiveRecord::Base end class AuthorFavorite < ActiveRecord::Base + belongs_to :author + belongs_to :favorite_author, class_name: "Author" +end + +class AuthorFavoriteWithScope < ActiveRecord::Base + self.table_name = "author_favorites" + default_scope { order(id: :asc) } belongs_to :author diff --git a/activerecord/test/models/developer.rb b/activerecord/test/models/developer.rb index ec48094207..c6574cf6e7 100644 --- a/activerecord/test/models/developer.rb +++ b/activerecord/test/models/developer.rb @@ -207,6 +207,7 @@ end class MultiplePoorDeveloperCalledJamis < ActiveRecord::Base self.table_name = "developers" + default_scope { } default_scope -> { where(name: "Jamis") } default_scope -> { where(salary: 50000) } end diff --git a/activerecord/test/models/post.rb b/activerecord/test/models/post.rb index c6eb77dba4..53cbda83ed 100644 --- a/activerecord/test/models/post.rb +++ b/activerecord/test/models/post.rb @@ -78,6 +78,7 @@ class Post < ActiveRecord::Base has_many :comments_with_extend_2, extend: [NamedExtension, NamedExtension2], class_name: "Comment", foreign_key: "post_id" has_many :author_favorites, through: :author + has_many :author_favorites_with_scope, through: :author, class_name: "AuthorFavoriteWithScope", source: "author_favorites" has_many :author_categorizations, through: :author, source: :categorizations has_many :author_addresses, through: :author has_many :author_address_extra_with_address, diff --git a/activerecord/test/models/topic.rb b/activerecord/test/models/topic.rb index 0c8880a20e..77101090f2 100644 --- a/activerecord/test/models/topic.rb +++ b/activerecord/test/models/topic.rb @@ -119,10 +119,6 @@ class Topic < ActiveRecord::Base end end -class ImportantTopic < Topic - serialize :important, Hash -end - class DefaultRejectedTopic < Topic default_scope -> { where(approved: false) } end diff --git a/guides/source/5_2_release_notes.md b/guides/source/5_2_release_notes.md index c5b914fffc..29b355119c 100644 --- a/guides/source/5_2_release_notes.md +++ b/guides/source/5_2_release_notes.md @@ -615,6 +615,10 @@ Please refer to the [Changelog][active-record] for detailed changes. the parent class was getting deleted when the child was not. ([Commit](https://github.com/rails/rails/commit/b0fc04aa3af338d5a90608bf37248668d59fc881)) +* Idle database connections (previously just orphaned connections) are now + periodically reaped by the connection pool reaper. + ([Commit](https://github.com/rails/rails/pull/31221/commits/9027fafff6da932e6e64ddb828665f4b01fc8902)) + Active Model ------------ diff --git a/guides/source/active_record_querying.md b/guides/source/active_record_querying.md index cb738f0657..270696d38d 100644 --- a/guides/source/active_record_querying.md +++ b/guides/source/active_record_querying.md @@ -807,6 +807,32 @@ SELECT * FROM articles WHERE id > 10 ORDER BY id DESC LIMIT 20 ``` +### `reselect` + +The `reselect` method overrides an existing select statement. For example: + +```ruby +Post.select(:title, :body).reselect(:created_at) +``` + +The SQL that would be executed: + +```sql +SELECT `posts.created_at` FROM `posts` +``` + +In case the `reselect` clause is not used, + +```ruby +Post.select(:title, :body) +``` + +the SQL executed would be: + +```sql +SELECT `posts.title`, `posts.body` FROM `posts` +``` + ### `reorder` The `reorder` method overrides the default scope order. For example: |