diff options
Diffstat (limited to 'activerecord')
132 files changed, 2364 insertions, 2134 deletions
diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 4aa7ecfc7e..89a12d4223 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,5 +1,86 @@ +* Add support for PostgreSQL operator classes to `add_index`. + + Example: + + add_index :users, :name, using: :gist, opclass: { name: :gist_trgm_ops } + + *Greg Navis* + +* Don't allow scopes to be defined which conflict with instance methods on `Relation`. + + Fixes #31120. + + *kinnrot* + + +## Rails 5.2.0.beta2 (November 28, 2017) ## + +* No changes. + + +## Rails 5.2.0.beta1 (November 27, 2017) ## + +* Add new error class `QueryCanceled` which will be raised + when canceling statement due to user request. + + *Ryuta Kamizono* + +* Add `#up_only` to database migrations for code that is only relevant when + migrating up, e.g. populating a new column. + + *Rich Daley* + +* Require raw SQL fragments to be explicitly marked when used in + relation query methods. + + Before: + ``` + Article.order("LENGTH(title)") + ``` + + After: + ``` + Article.order(Arel.sql("LENGTH(title)")) + ``` + + This prevents SQL injection if applications use the [strongly + discouraged] form `Article.order(params[:my_order])`, under the + mistaken belief that only column names will be accepted. + + Raw SQL strings will now cause a deprecation warning, which will + become an UnknownAttributeReference error in Rails 6.0. Applications + can opt in to the future behavior by setting `allow_unsafe_raw_sql` + to `:disabled`. + + Common and judged-safe string values (such as simple column + references) are unaffected: + ``` + Article.order("title DESC") + ``` + + *Ben Toews* + +* `update_all` will now pass its values to `Type#cast` before passing them to + `Type#serialize`. This means that `update_all(foo: 'true')` will properly + persist a boolean. + + *Sean Griffin* + +* Add new error class `StatementTimeout` which will be raised + when statement timeout exceeded. + + *Ryuta Kamizono* + +* Fix `bin/rails db:migrate` with specified `VERSION`. + `bin/rails db:migrate` with empty VERSION behaves as without `VERSION`. + Check a format of `VERSION`: Allow a migration version number + or name of a migration file. Raise error if format of `VERSION` is invalid. + Raise error if target migration doesn't exist. + + *bogdanvlviv* + * Fixed a bug where column orders for an index weren't written to - db/schema.rb when using the sqlite adapter. + `db/schema.rb` when using the sqlite adapter. Fixes #30902. @@ -136,8 +217,8 @@ *Jeremy Green* -* Add new error class `TransactionTimeout` for MySQL adapter which will be raised - when lock wait time expires. +* Add new error class `LockWaitTimeout` which will be raised + when lock wait timeout exceeded. *Gabriel Courtemanche* diff --git a/activerecord/README.rdoc b/activerecord/README.rdoc index ba83a9adb2..19650b82ae 100644 --- a/activerecord/README.rdoc +++ b/activerecord/README.rdoc @@ -208,7 +208,7 @@ API documentation is at: * http://api.rubyonrails.org -Bug reports can be filed for the Ruby on Rails project here: +Bug reports for the Ruby on Rails project can be filed here: * https://github.com/rails/rails/issues diff --git a/activerecord/activerecord.gemspec b/activerecord/activerecord.gemspec index 7ad06fe840..8e42a11df4 100644 --- a/activerecord/activerecord.gemspec +++ b/activerecord/activerecord.gemspec @@ -31,5 +31,5 @@ Gem::Specification.new do |s| s.add_dependency "activesupport", version s.add_dependency "activemodel", version - s.add_dependency "arel", "9.0.0.alpha" + s.add_dependency "arel", ">= 9.0" end diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index bf6dfd46e1..5de6503144 100644 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -27,14 +27,14 @@ require "active_support" require "active_support/rails" require "active_model" require "arel" +require "yaml" require "active_record/version" -require "active_record/attribute_set" +require "active_model/attribute_set" module ActiveRecord extend ActiveSupport::Autoload - autoload :Attribute autoload :Base autoload :Callbacks autoload :Core @@ -104,6 +104,7 @@ module ActiveRecord autoload :Result autoload :TableMetadata + autoload :Type end module Coders @@ -181,3 +182,7 @@ end ActiveSupport.on_load(:i18n) do I18n.load_path << File.expand_path("active_record/locale/en.yml", __dir__) end + +YAML.load_tags["!ruby/object:ActiveRecord::AttributeSet"] = "ActiveModel::AttributeSet" +YAML.load_tags["!ruby/object:ActiveRecord::Attribute::FromDatabase"] = "ActiveModel::Attribute::FromDatabase" +YAML.load_tags["!ruby/object:ActiveRecord::LazyAttributeHash"] = "ActiveModel::LazyAttributeHash" diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb index ed215fb22c..921237a735 100644 --- a/activerecord/lib/active_record/associations/collection_association.rb +++ b/activerecord/lib/active_record/associations/collection_association.rb @@ -79,7 +79,13 @@ module ActiveRecord def find(*args) if options[:inverse_of] && loaded? args_flatten = args.flatten - raise RecordNotFound, "Couldn't find #{scope.klass.name} without an ID" if args_flatten.blank? + model = scope.klass + + if args_flatten.blank? + error_message = "Couldn't find #{model.name} without an ID" + raise RecordNotFound.new(error_message, model.name, model.primary_key, args) + end + result = find_by_scan(*args) result_size = Array(result).size diff --git a/activerecord/lib/active_record/associations/collection_proxy.rb b/activerecord/lib/active_record/associations/collection_proxy.rb index 07f7303f8d..8b4a48a38c 100644 --- a/activerecord/lib/active_record/associations/collection_proxy.rb +++ b/activerecord/lib/active_record/associations/collection_proxy.rb @@ -988,6 +988,12 @@ module ActiveRecord load_target == other end + ## + # :method: to_ary + # + # :call-seq: + # to_ary() + # # Returns a new array of objects from the collection. If the collection # hasn't been loaded, it fetches the records from the database. # @@ -1021,10 +1027,6 @@ module ActiveRecord # # #<Pet id: 5, name: "Brain", person_id: 1>, # # #<Pet id: 6, name: "Boss", person_id: 1> # # ] - def to_ary - load_target.dup - end - alias_method :to_a, :to_ary def records # :nodoc: load_target diff --git a/activerecord/lib/active_record/associations/preloader.rb b/activerecord/lib/active_record/associations/preloader.rb index e1754d4a19..e1087be9b3 100644 --- a/activerecord/lib/active_record/associations/preloader.rb +++ b/activerecord/lib/active_record/associations/preloader.rb @@ -44,16 +44,8 @@ module ActiveRecord extend ActiveSupport::Autoload eager_autoload do - autoload :Association, "active_record/associations/preloader/association" - autoload :SingularAssociation, "active_record/associations/preloader/singular_association" - autoload :CollectionAssociation, "active_record/associations/preloader/collection_association" - autoload :ThroughAssociation, "active_record/associations/preloader/through_association" - - autoload :HasMany, "active_record/associations/preloader/has_many" - autoload :HasManyThrough, "active_record/associations/preloader/has_many_through" - autoload :HasOne, "active_record/associations/preloader/has_one" - autoload :HasOneThrough, "active_record/associations/preloader/has_one_through" - autoload :BelongsTo, "active_record/associations/preloader/belongs_to" + autoload :Association, "active_record/associations/preloader/association" + autoload :ThroughAssociation, "active_record/associations/preloader/through_association" end # Eager loads the named associations for the given Active Record record(s). @@ -166,8 +158,6 @@ module ActiveRecord end class AlreadyLoaded # :nodoc: - attr_reader :owners, :reflection - def initialize(klass, owners, reflection, preload_scope) @owners = owners @reflection = reflection @@ -178,11 +168,13 @@ module ActiveRecord def preloaded_records owners.flat_map { |owner| owner.association(reflection.name).target } end + + protected + attr_reader :owners, :reflection end # Returns a class containing the logic needed to load preload the data - # and attach it to a relation. For example +Preloader::Association+ or - # +Preloader::HasManyThrough+. The class returned implements a `run` method + # and attach it to a relation. The class returned implements a `run` method # that accepts a preloader. def preloader_for(reflection, owners) if owners.first.association(reflection.name).loaded? @@ -190,13 +182,10 @@ module ActiveRecord end reflection.check_preloadable! - case reflection.macro - when :has_many - reflection.options[:through] ? HasManyThrough : HasMany - when :has_one - reflection.options[:through] ? HasOneThrough : HasOne - when :belongs_to - BelongsTo + if reflection.options[:through] + ThroughAssociation + else + Association end end end diff --git a/activerecord/lib/active_record/associations/preloader/association.rb b/activerecord/lib/active_record/associations/preloader/association.rb index 607d376a08..735da152b7 100644 --- a/activerecord/lib/active_record/associations/preloader/association.rb +++ b/activerecord/lib/active_record/associations/preloader/association.rb @@ -4,7 +4,6 @@ module ActiveRecord module Associations class Preloader class Association #:nodoc: - attr_reader :owners, :reflection, :preload_scope, :model, :klass attr_reader :preloaded_records def initialize(klass, owners, reflection, preload_scope) @@ -17,11 +16,20 @@ module ActiveRecord end def run(preloader) - associated_records_by_owner(preloader).each do |owner, records| - associate_records_to_owner(owner, records) + records = load_records do |record| + owner = owners_by_key[convert_key(record[association_key_name])] + association = owner.association(reflection.name) + association.set_inverse_instance(record) + end + + owners.each do |owner| + associate_records_to_owner(owner, records[convert_key(owner[owner_key_name])] || []) end end + protected + attr_reader :owners, :reflection, :preload_scope, :model, :klass + private # The name of the key on the associated records def association_key_name @@ -33,20 +41,14 @@ module ActiveRecord reflection.join_foreign_key end - def associated_records_by_owner(preloader) - records = load_records do |record| - owner = owners_by_key[convert_key(record[association_key_name])] - association = owner.association(reflection.name) - association.set_inverse_instance(record) - end - - owners.each_with_object({}) do |owner, result| - result[owner] = records[convert_key(owner[owner_key_name])] || [] - end - end - def associate_records_to_owner(owner, records) - raise NotImplementedError + association = owner.association(reflection.name) + if reflection.collection? + association.loaded! + association.target.concat(records) + else + association.target = records.first + end end def owner_keys diff --git a/activerecord/lib/active_record/associations/preloader/belongs_to.rb b/activerecord/lib/active_record/associations/preloader/belongs_to.rb deleted file mode 100644 index a8e3340b23..0000000000 --- a/activerecord/lib/active_record/associations/preloader/belongs_to.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - module Associations - class Preloader - class BelongsTo < SingularAssociation #:nodoc: - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader/collection_association.rb b/activerecord/lib/active_record/associations/preloader/collection_association.rb deleted file mode 100644 index fc2029f54a..0000000000 --- a/activerecord/lib/active_record/associations/preloader/collection_association.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - module Associations - class Preloader - class CollectionAssociation < Association #:nodoc: - private - def associate_records_to_owner(owner, records) - association = owner.association(reflection.name) - association.loaded! - association.target.concat(records) - end - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader/has_many.rb b/activerecord/lib/active_record/associations/preloader/has_many.rb deleted file mode 100644 index 72f55bc43f..0000000000 --- a/activerecord/lib/active_record/associations/preloader/has_many.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - module Associations - class Preloader - class HasMany < CollectionAssociation #:nodoc: - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader/has_many_through.rb b/activerecord/lib/active_record/associations/preloader/has_many_through.rb deleted file mode 100644 index 0639fdca44..0000000000 --- a/activerecord/lib/active_record/associations/preloader/has_many_through.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - module Associations - class Preloader - class HasManyThrough < CollectionAssociation #:nodoc: - include ThroughAssociation - - def associated_records_by_owner(preloader) - records_by_owner = super - - if reflection_scope.distinct_value - records_by_owner.each_value(&:uniq!) - end - - records_by_owner - end - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader/has_one.rb b/activerecord/lib/active_record/associations/preloader/has_one.rb deleted file mode 100644 index e339b65fb5..0000000000 --- a/activerecord/lib/active_record/associations/preloader/has_one.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - module Associations - class Preloader - class HasOne < SingularAssociation #:nodoc: - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader/has_one_through.rb b/activerecord/lib/active_record/associations/preloader/has_one_through.rb deleted file mode 100644 index 17734d0257..0000000000 --- a/activerecord/lib/active_record/associations/preloader/has_one_through.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - module Associations - class Preloader - class HasOneThrough < SingularAssociation #:nodoc: - include ThroughAssociation - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader/singular_association.rb b/activerecord/lib/active_record/associations/preloader/singular_association.rb deleted file mode 100644 index 30a92411e3..0000000000 --- a/activerecord/lib/active_record/associations/preloader/singular_association.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - module Associations - class Preloader - class SingularAssociation < Association #:nodoc: - private - def associate_records_to_owner(owner, records) - association = owner.association(reflection.name) - association.target = records.first - end - end - end - end -end diff --git a/activerecord/lib/active_record/associations/preloader/through_association.rb b/activerecord/lib/active_record/associations/preloader/through_association.rb index fa32cc5553..a6b7ab80a2 100644 --- a/activerecord/lib/active_record/associations/preloader/through_association.rb +++ b/activerecord/lib/active_record/associations/preloader/through_association.rb @@ -3,78 +3,54 @@ module ActiveRecord module Associations class Preloader - module ThroughAssociation #:nodoc: - def through_reflection - reflection.through_reflection - end - - def source_reflection - reflection.source_reflection - end - - def associated_records_by_owner(preloader) - through_scope = through_scope() - - preloader.preload(owners, - through_reflection.name, - through_scope) - - through_records = owners.map do |owner| - center = owner.association(through_reflection.name).target - [owner, Array(center)] - end - - reset_association(owners, through_reflection.name, through_scope) - - middle_records = through_records.flat_map(&:last) - - reflection_scope = reflection_scope() if reflection.scope - - preloaders = preloader.preload(middle_records, - source_reflection.name, - reflection_scope) - + class ThroughAssociation < Association # :nodoc: + def run(preloader) + already_loaded = owners.first.association(through_reflection.name).loaded? + through_scope = through_scope() + reflection_scope = target_reflection_scope + through_preloaders = preloader.preload(owners, through_reflection.name, through_scope) + middle_records = through_preloaders.flat_map(&:preloaded_records) + preloaders = preloader.preload(middle_records, source_reflection.name, reflection_scope) @preloaded_records = preloaders.flat_map(&:preloaded_records) - middle_to_pl = preloaders.each_with_object({}) do |pl, h| - pl.owners.each { |middle| - h[middle] = pl - } - end - - through_records.each_with_object({}) do |(lhs, center), records_by_owner| - pl_to_middle = center.group_by { |record| middle_to_pl[record] } - - records_by_owner[lhs] = pl_to_middle.flat_map do |pl, middles| - rhs_records = middles.flat_map { |r| - r.association(source_reflection.name).target - }.compact - - # Respect the order on `reflection_scope` if it exists, else use the natural order. - if reflection_scope && !reflection_scope.order_values.empty? - @id_map ||= id_to_index_map @preloaded_records - rhs_records.sort_by { |rhs| @id_map[rhs] } - else - rhs_records + owners.each do |owner| + through_records = Array(owner.association(through_reflection.name).target) + if already_loaded + if source_type = reflection.options[:source_type] + through_records = through_records.select do |record| + record[reflection.foreign_type] == source_type + end end + else + owner.association(through_reflection.name).reset if through_scope + end + result = through_records.flat_map do |record| + association = record.association(source_reflection.name) + target = association.target + association.reset if preload_scope + target end + result.compact! + if reflection_scope + result.sort_by! { |rhs| preload_index[rhs] } if reflection_scope.order_values.any? + result.uniq! if reflection_scope.distinct_value + end + associate_records_to_owner(owner, result) end end private + def through_reflection + reflection.through_reflection + end - def id_to_index_map(ids) - id_map = {} - ids.each_with_index { |id, index| id_map[id] = index } - id_map + def source_reflection + reflection.source_reflection end - def reset_association(owners, association_name, should_reset) - # Don't cache the association - we would only be caching a subset - if should_reset - owners.each { |owner| - owner.association(association_name).reset - } + def preload_index + @preload_index ||= @preloaded_records.each_with_object({}).with_index do |(id, result), index| + result[id] = index end end @@ -115,6 +91,16 @@ module ActiveRecord scope unless scope.empty_scope? end + + def target_reflection_scope + if preload_scope + reflection_scope.merge(preload_scope) + elsif reflection.scope + reflection_scope + else + nil + end + end end end end diff --git a/activerecord/lib/active_record/attribute.rb b/activerecord/lib/active_record/attribute.rb deleted file mode 100644 index fc474edc15..0000000000 --- a/activerecord/lib/active_record/attribute.rb +++ /dev/null @@ -1,242 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - class Attribute # :nodoc: - class << self - def from_database(name, value, type) - FromDatabase.new(name, value, type) - end - - def from_user(name, value, type, original_attribute = nil) - FromUser.new(name, value, type, original_attribute) - end - - def with_cast_value(name, value, type) - WithCastValue.new(name, value, type) - end - - def null(name) - Null.new(name) - end - - def uninitialized(name, type) - Uninitialized.new(name, type) - end - end - - attr_reader :name, :value_before_type_cast, :type - - # This method should not be called directly. - # Use #from_database or #from_user - def initialize(name, value_before_type_cast, type, original_attribute = nil) - @name = name - @value_before_type_cast = value_before_type_cast - @type = type - @original_attribute = original_attribute - end - - def value - # `defined?` is cheaper than `||=` when we get back falsy values - @value = type_cast(value_before_type_cast) unless defined?(@value) - @value - end - - def original_value - if assigned? - original_attribute.original_value - else - type_cast(value_before_type_cast) - end - end - - def value_for_database - type.serialize(value) - end - - def changed? - changed_from_assignment? || changed_in_place? - end - - def changed_in_place? - has_been_read? && type.changed_in_place?(original_value_for_database, value) - end - - def forgetting_assignment - with_value_from_database(value_for_database) - end - - def with_value_from_user(value) - type.assert_valid_value(value) - self.class.from_user(name, value, type, original_attribute || self) - end - - def with_value_from_database(value) - self.class.from_database(name, value, type) - end - - def with_cast_value(value) - self.class.with_cast_value(name, value, type) - end - - def with_type(type) - if changed_in_place? - with_value_from_user(value).with_type(type) - else - self.class.new(name, value_before_type_cast, type, original_attribute) - end - end - - def type_cast(*) - raise NotImplementedError - end - - def initialized? - true - end - - def came_from_user? - false - end - - def has_been_read? - defined?(@value) - end - - def ==(other) - self.class == other.class && - name == other.name && - value_before_type_cast == other.value_before_type_cast && - type == other.type - end - alias eql? == - - def hash - [self.class, name, value_before_type_cast, type].hash - end - - def init_with(coder) - @name = coder["name"] - @value_before_type_cast = coder["value_before_type_cast"] - @type = coder["type"] - @original_attribute = coder["original_attribute"] - @value = coder["value"] if coder.map.key?("value") - end - - def encode_with(coder) - coder["name"] = name - coder["value_before_type_cast"] = value_before_type_cast unless value_before_type_cast.nil? - coder["type"] = type if type - coder["original_attribute"] = original_attribute if original_attribute - coder["value"] = value if defined?(@value) - end - - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :original_attribute - alias_method :assigned?, :original_attribute - - def original_value_for_database - if assigned? - original_attribute.original_value_for_database - else - _original_value_for_database - end - end - - private - def initialize_dup(other) - if defined?(@value) && @value.duplicable? - @value = @value.dup - end - end - - def changed_from_assignment? - assigned? && type.changed?(original_value, value, value_before_type_cast) - end - - def _original_value_for_database - type.serialize(original_value) - end - - class FromDatabase < Attribute # :nodoc: - def type_cast(value) - type.deserialize(value) - end - - def _original_value_for_database - value_before_type_cast - end - end - - class FromUser < Attribute # :nodoc: - def type_cast(value) - type.cast(value) - end - - def came_from_user? - !type.value_constructed_by_mass_assignment?(value_before_type_cast) - end - end - - class WithCastValue < Attribute # :nodoc: - def type_cast(value) - value - end - - def changed_in_place? - false - end - end - - class Null < Attribute # :nodoc: - def initialize(name) - super(name, nil, Type.default_value) - end - - def type_cast(*) - nil - end - - def with_type(type) - self.class.with_cast_value(name, nil, type) - end - - def with_value_from_database(value) - raise ActiveModel::MissingAttributeError, "can't write unknown attribute `#{name}`" - end - alias_method :with_value_from_user, :with_value_from_database - end - - class Uninitialized < Attribute # :nodoc: - UNINITIALIZED_ORIGINAL_VALUE = Object.new - - def initialize(name, type) - super(name, nil, type) - end - - def value - if block_given? - yield name - end - end - - def original_value - UNINITIALIZED_ORIGINAL_VALUE - end - - def value_for_database - end - - def initialized? - false - end - - def with_type(type) - self.class.new(name, type) - end - end - private_constant :FromDatabase, :FromUser, :Null, :Uninitialized, :WithCastValue - end -end diff --git a/activerecord/lib/active_record/attribute/user_provided_default.rb b/activerecord/lib/active_record/attribute/user_provided_default.rb deleted file mode 100644 index f746960fac..0000000000 --- a/activerecord/lib/active_record/attribute/user_provided_default.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -require "active_record/attribute" - -module ActiveRecord - class Attribute # :nodoc: - class UserProvidedDefault < FromUser # :nodoc: - def initialize(name, value, type, database_default) - @user_provided_value = value - super(name, value, type, database_default) - end - - def value_before_type_cast - if user_provided_value.is_a?(Proc) - @memoized_value_before_type_cast ||= user_provided_value.call - else - @user_provided_value - end - end - - def with_type(type) - self.class.new(name, user_provided_value, type, original_attribute) - end - - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :user_provided_value - end - end -end diff --git a/activerecord/lib/active_record/attribute_methods.rb b/activerecord/lib/active_record/attribute_methods.rb index 23d2aef214..64f81ca582 100644 --- a/activerecord/lib/active_record/attribute_methods.rb +++ b/activerecord/lib/active_record/attribute_methods.rb @@ -167,6 +167,46 @@ module ActiveRecord end end + # Regexp whitelist. Matches the following: + # "#{table_name}.#{column_name}" + # "#{column_name}" + COLUMN_NAME_WHITELIST = /\A(?:\w+\.)?\w+\z/i + + # Regexp whitelist. Matches the following: + # "#{table_name}.#{column_name}" + # "#{table_name}.#{column_name} #{direction}" + # "#{column_name}" + # "#{column_name} #{direction}" + COLUMN_NAME_ORDER_WHITELIST = /\A(?:\w+\.)?\w+(?:\s+asc|\s+desc)?\z/i + + def enforce_raw_sql_whitelist(args, whitelist: COLUMN_NAME_WHITELIST) # :nodoc: + unexpected = args.reject do |arg| + arg.kind_of?(Arel::Node) || + arg.is_a?(Arel::Nodes::SqlLiteral) || + arg.is_a?(Arel::Attributes::Attribute) || + arg.to_s.split(/\s*,\s*/).all? { |part| whitelist.match?(part) } + end + + return if unexpected.none? + + if allow_unsafe_raw_sql == :deprecated + ActiveSupport::Deprecation.warn( + "Dangerous query method (method whose arguments are used as raw " \ + "SQL) called with non-attribute argument(s): " \ + "#{unexpected.map(&:inspect).join(", ")}. Non-attribute " \ + "arguments will be disallowed in Rails 6.0. This method should " \ + "not be called with user-provided values, such as request " \ + "parameters or model attributes. Known-safe values can be passed " \ + "by wrapping them in Arel.sql()." + ) + else + raise(ActiveRecord::UnknownAttributeReference, + "Query method called with non-attribute argument(s): " + + unexpected.map(&:inspect).join(", ") + ) + end + end + # Returns true if the given attribute exists, otherwise false. # # class Person < ActiveRecord::Base diff --git a/activerecord/lib/active_record/attribute_methods/dirty.rb b/activerecord/lib/active_record/attribute_methods/dirty.rb index 79110d04f4..3de6fe566d 100644 --- a/activerecord/lib/active_record/attribute_methods/dirty.rb +++ b/activerecord/lib/active_record/attribute_methods/dirty.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "active_support/core_ext/module/attribute_accessors" -require "active_record/attribute_mutation_tracker" module ActiveRecord module AttributeMethods @@ -33,65 +32,13 @@ module ActiveRecord # <tt>reload</tt> the record and clears changed attributes. def reload(*) super.tap do + @previously_changed = ActiveSupport::HashWithIndifferentAccess.new @mutations_before_last_save = nil + @attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new @mutations_from_database = nil - @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new end end - def initialize_dup(other) # :nodoc: - super - @attributes = self.class._default_attributes.map do |attr| - attr.with_value_from_user(@attributes.fetch_value(attr.name)) - end - @mutations_from_database = nil - end - - def changes_applied # :nodoc: - @mutations_before_last_save = mutations_from_database - @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new - forget_attribute_assignments - @mutations_from_database = nil - end - - def clear_changes_information # :nodoc: - @mutations_before_last_save = nil - @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new - forget_attribute_assignments - @mutations_from_database = nil - end - - def clear_attribute_changes(attr_names) # :nodoc: - super - attr_names.each do |attr_name| - clear_attribute_change(attr_name) - end - end - - def changed_attributes # :nodoc: - # This should only be set by methods which will call changed_attributes - # multiple times when it is known that the computed value cannot change. - if defined?(@cached_changed_attributes) - @cached_changed_attributes - else - super.reverse_merge(mutations_from_database.changed_values).freeze - end - end - - def changes # :nodoc: - cache_changed_attributes do - super - end - end - - def previous_changes # :nodoc: - mutations_before_last_save.changes - end - - def attribute_changed_in_place?(attr_name) # :nodoc: - mutations_from_database.changed_in_place?(attr_name) - end - # Did this attribute change when we last saved? This method can be invoked # as +saved_change_to_name?+ instead of <tt>saved_change_to_attribute?("name")</tt>. # Behaves similarly to +attribute_changed?+. This method is useful in @@ -182,26 +129,6 @@ module ActiveRecord result end - def mutations_from_database - unless defined?(@mutations_from_database) - @mutations_from_database = nil - end - @mutations_from_database ||= AttributeMutationTracker.new(@attributes) - end - - def changes_include?(attr_name) - super || mutations_from_database.changed?(attr_name) - end - - def clear_attribute_change(attr_name) - mutations_from_database.forget_change(attr_name) - end - - def attribute_will_change!(attr_name) - super - mutations_from_database.force_change(attr_name) - end - def _update_record(*) partial_writes? ? super(keys_for_partial_write) : super end @@ -213,25 +140,6 @@ module ActiveRecord def keys_for_partial_write changed_attribute_names_to_save & self.class.column_names end - - def forget_attribute_assignments - @attributes = @attributes.map(&:forgetting_assignment) - end - - def mutations_before_last_save - @mutations_before_last_save ||= NullMutationTracker.instance - end - - def cache_changed_attributes - @cached_changed_attributes = changed_attributes - yield - ensure - clear_changed_attributes_cache - end - - def clear_changed_attributes_cache - remove_instance_variable(:@cached_changed_attributes) if defined?(@cached_changed_attributes) - end end end end diff --git a/activerecord/lib/active_record/attribute_mutation_tracker.rb b/activerecord/lib/active_record/attribute_mutation_tracker.rb deleted file mode 100644 index 94bf641a5d..0000000000 --- a/activerecord/lib/active_record/attribute_mutation_tracker.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - class AttributeMutationTracker # :nodoc: - OPTION_NOT_GIVEN = Object.new - - def initialize(attributes) - @attributes = attributes - @forced_changes = Set.new - end - - def changed_values - attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result| - if changed?(attr_name) - result[attr_name] = attributes[attr_name].original_value - end - end - end - - def changes - attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result| - change = change_to_attribute(attr_name) - if change - result[attr_name] = change - end - end - end - - def change_to_attribute(attr_name) - attr_name = attr_name.to_s - if changed?(attr_name) - [attributes[attr_name].original_value, attributes.fetch_value(attr_name)] - end - end - - def any_changes? - attr_names.any? { |attr| changed?(attr) } - end - - def changed?(attr_name, from: OPTION_NOT_GIVEN, to: OPTION_NOT_GIVEN) - attr_name = attr_name.to_s - forced_changes.include?(attr_name) || - attributes[attr_name].changed? && - (OPTION_NOT_GIVEN == from || attributes[attr_name].original_value == from) && - (OPTION_NOT_GIVEN == to || attributes[attr_name].value == to) - end - - def changed_in_place?(attr_name) - attributes[attr_name.to_s].changed_in_place? - end - - def forget_change(attr_name) - attr_name = attr_name.to_s - attributes[attr_name] = attributes[attr_name].forgetting_assignment - forced_changes.delete(attr_name) - end - - def original_value(attr_name) - attributes[attr_name.to_s].original_value - end - - def force_change(attr_name) - forced_changes << attr_name.to_s - end - - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :attributes, :forced_changes - - private - - def attr_names - attributes.keys - end - end - - class NullMutationTracker # :nodoc: - include Singleton - - def changed_values(*) - {} - end - - def changes(*) - {} - end - - def change_to_attribute(attr_name) - end - - def any_changes?(*) - false - end - - def changed?(*) - false - end - - def changed_in_place?(*) - false - end - - def forget_change(*) - end - - def original_value(*) - end - end -end diff --git a/activerecord/lib/active_record/attribute_set.rb b/activerecord/lib/active_record/attribute_set.rb deleted file mode 100644 index 9785666e77..0000000000 --- a/activerecord/lib/active_record/attribute_set.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -require "active_record/attribute_set/builder" -require "active_record/attribute_set/yaml_encoder" - -module ActiveRecord - class AttributeSet # :nodoc: - delegate :each_value, :fetch, to: :attributes - - def initialize(attributes) - @attributes = attributes - end - - def [](name) - attributes[name] || Attribute.null(name) - end - - def []=(name, value) - attributes[name] = value - end - - def values_before_type_cast - attributes.transform_values(&:value_before_type_cast) - end - - def to_hash - initialized_attributes.transform_values(&:value) - end - alias_method :to_h, :to_hash - - def key?(name) - attributes.key?(name) && self[name].initialized? - end - - def keys - attributes.each_key.select { |name| self[name].initialized? } - end - - if defined?(JRUBY_VERSION) - # This form is significantly faster on JRuby, and this is one of our biggest hotspots. - # https://github.com/jruby/jruby/pull/2562 - def fetch_value(name, &block) - self[name].value(&block) - end - else - def fetch_value(name) - self[name].value { |n| yield n if block_given? } - end - end - - def write_from_database(name, value) - attributes[name] = self[name].with_value_from_database(value) - end - - def write_from_user(name, value) - attributes[name] = self[name].with_value_from_user(value) - end - - def write_cast_value(name, value) - attributes[name] = self[name].with_cast_value(value) - end - - def freeze - @attributes.freeze - super - end - - def deep_dup - self.class.allocate.tap do |copy| - copy.instance_variable_set(:@attributes, attributes.deep_dup) - end - end - - def initialize_dup(_) - @attributes = attributes.dup - super - end - - def initialize_clone(_) - @attributes = attributes.clone - super - end - - def reset(key) - if key?(key) - write_from_database(key, nil) - end - end - - def accessed - attributes.select { |_, attr| attr.has_been_read? }.keys - end - - def map(&block) - new_attributes = attributes.transform_values(&block) - AttributeSet.new(new_attributes) - end - - def ==(other) - attributes == other.attributes - end - - protected - - attr_reader :attributes - - private - - def initialized_attributes - attributes.select { |_, attr| attr.initialized? } - end - end -end diff --git a/activerecord/lib/active_record/attribute_set/builder.rb b/activerecord/lib/active_record/attribute_set/builder.rb deleted file mode 100644 index 349cc7e403..0000000000 --- a/activerecord/lib/active_record/attribute_set/builder.rb +++ /dev/null @@ -1,126 +0,0 @@ -# frozen_string_literal: true - -require "active_record/attribute" - -module ActiveRecord - class AttributeSet # :nodoc: - class Builder # :nodoc: - attr_reader :types, :always_initialized, :default - - def initialize(types, always_initialized = nil, &default) - @types = types - @always_initialized = always_initialized - @default = default - end - - def build_from_database(values = {}, additional_types = {}) - if always_initialized && !values.key?(always_initialized) - values[always_initialized] = nil - end - - attributes = LazyAttributeHash.new(types, values, additional_types, &default) - AttributeSet.new(attributes) - end - end - end - - class LazyAttributeHash # :nodoc: - delegate :transform_values, :each_key, :each_value, :fetch, to: :materialize - - def initialize(types, values, additional_types, &default) - @types = types - @values = values - @additional_types = additional_types - @materialized = false - @delegate_hash = {} - @default = default || proc {} - end - - def key?(key) - delegate_hash.key?(key) || values.key?(key) || types.key?(key) - end - - def [](key) - delegate_hash[key] || assign_default_value(key) - end - - def []=(key, value) - if frozen? - raise RuntimeError, "Can't modify frozen hash" - end - delegate_hash[key] = value - end - - def deep_dup - dup.tap do |copy| - copy.instance_variable_set(:@delegate_hash, delegate_hash.transform_values(&:dup)) - end - end - - def initialize_dup(_) - @delegate_hash = Hash[delegate_hash] - super - end - - def select - keys = types.keys | values.keys | delegate_hash.keys - keys.each_with_object({}) do |key, hash| - attribute = self[key] - if yield(key, attribute) - hash[key] = attribute - end - end - end - - def ==(other) - if other.is_a?(LazyAttributeHash) - materialize == other.materialize - else - materialize == other - end - end - - def marshal_dump - materialize - end - - def marshal_load(delegate_hash) - @delegate_hash = delegate_hash - @types = {} - @values = {} - @additional_types = {} - @materialized = true - end - - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :types, :values, :additional_types, :delegate_hash, :default - - def materialize - unless @materialized - values.each_key { |key| self[key] } - types.each_key { |key| self[key] } - unless frozen? - @materialized = true - end - end - delegate_hash - end - - private - - def assign_default_value(name) - type = additional_types.fetch(name, types[name]) - value_present = true - value = values.fetch(name) { value_present = false } - - if value_present - delegate_hash[name] = Attribute.from_database(name, value, type) - elsif types.key?(name) - delegate_hash[name] = default.call(name) || Attribute.uninitialized(name, type) - end - end - end -end diff --git a/activerecord/lib/active_record/attribute_set/yaml_encoder.rb b/activerecord/lib/active_record/attribute_set/yaml_encoder.rb deleted file mode 100644 index 9254ce16ab..0000000000 --- a/activerecord/lib/active_record/attribute_set/yaml_encoder.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - class AttributeSet - # Attempts to do more intelligent YAML dumping of an - # ActiveRecord::AttributeSet to reduce the size of the resulting string - class YAMLEncoder # :nodoc: - def initialize(default_types) - @default_types = default_types - end - - def encode(attribute_set, coder) - coder["concise_attributes"] = attribute_set.each_value.map do |attr| - if attr.type.equal?(default_types[attr.name]) - attr.with_type(nil) - else - attr - end - end - end - - def decode(coder) - if coder["attributes"] - coder["attributes"] - else - attributes_hash = Hash[coder["concise_attributes"].map do |attr| - if attr.type.nil? - attr = attr.with_type(default_types[attr.name]) - end - [attr.name, attr] - end] - AttributeSet.new(attributes_hash) - end - end - - # TODO Change this to private once we've dropped Ruby 2.2 support. - # Workaround for Ruby 2.2 "private attribute?" warning. - protected - - attr_reader :default_types - end - end -end diff --git a/activerecord/lib/active_record/attributes.rb b/activerecord/lib/active_record/attributes.rb index 9224d58928..0b7c9398a8 100644 --- a/activerecord/lib/active_record/attributes.rb +++ b/activerecord/lib/active_record/attributes.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "active_record/attribute/user_provided_default" +require "active_model/attribute/user_provided_default" module ActiveRecord # See ActiveRecord::Attributes::ClassMethods for documentation @@ -250,14 +250,14 @@ module ActiveRecord if value == NO_DEFAULT_PROVIDED default_attribute = _default_attributes[name].with_type(type) elsif from_user - default_attribute = Attribute::UserProvidedDefault.new( + default_attribute = ActiveModel::Attribute::UserProvidedDefault.new( name, value, type, _default_attributes.fetch(name.to_s) { nil }, ) else - default_attribute = Attribute.from_database(name, value, type) + default_attribute = ActiveModel::Attribute.from_database(name, value, type) end _default_attributes[name] = default_attribute end diff --git a/activerecord/lib/active_record/autosave_association.rb b/activerecord/lib/active_record/autosave_association.rb index 6974cf74f6..a1250c3835 100644 --- a/activerecord/lib/active_record/autosave_association.rb +++ b/activerecord/lib/active_record/autosave_association.rb @@ -436,6 +436,9 @@ module ActiveRecord if (autosave && record.changed_for_autosave?) || new_record? || record_changed?(reflection, record, key) unless reflection.through_reflection record[reflection.foreign_key] = key + if inverse_reflection = reflection.inverse_of + record.association(inverse_reflection.name).loaded! + end end saved = record.save(validate: !autosave) diff --git a/activerecord/lib/active_record/collection_cache_key.rb b/activerecord/lib/active_record/collection_cache_key.rb index f3e6516414..88b398ad45 100644 --- a/activerecord/lib/active_record/collection_cache_key.rb +++ b/activerecord/lib/active_record/collection_cache_key.rb @@ -12,6 +12,9 @@ module ActiveRecord timestamp = collection.max_by(×tamp_column)._read_attribute(timestamp_column) end else + if collection.eager_loading? + collection = collection.send(:apply_join_dependency) + end column_type = type_for_attribute(timestamp_column.to_s) column = connection.column_name_from_arel_node(collection.arel_attribute(timestamp_column)) select_values = "COUNT(*) AS #{connection.quote_column_name("size")}, MAX(%s) AS timestamp" diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 0759f4d2b3..9849f9d5d7 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -63,15 +63,13 @@ module ActiveRecord # There are several connection-pooling-related options that you can add to # your database connection configuration: # - # * +pool+: number indicating size of connection pool (default 5) - # * +checkout_timeout+: number of seconds to block and wait for a connection - # before giving up and raising a timeout error (default 5 seconds). - # * +reaping_frequency+: frequency in seconds to periodically run the - # Reaper, which attempts to find and recover connections from dead - # threads, which can occur if a programmer forgets to close a - # connection at the end of a thread or a thread dies unexpectedly. - # Regardless of this setting, the Reaper will be invoked before every - # blocking wait. (Default +nil+, which means don't schedule the Reaper). + # * +pool+: maximum number of connections the pool may manage (default 5). + # * +idle_timeout+: number of seconds that a connection will be kept + # unused in the pool before it is automatically disconnected (default + # 300 seconds). Set this to zero to keep connections forever. + # * +checkout_timeout+: number of seconds to wait for a connection to + # become available before giving up and raising a timeout error (default + # 5 seconds). # #-- # Synchronization policy: @@ -82,11 +80,8 @@ module ActiveRecord # * private methods that require being called in a +synchronize+ blocks # are now explicitly documented class ConnectionPool - # Threadsafe, fair, FIFO queue. Meant to be used by ConnectionPool - # with which it shares a Monitor. But could be a generic Queue. - # - # The Queue in stdlib's 'thread' could replace this class except - # stdlib's doesn't support waiting with a timeout. + # Threadsafe, fair, LIFO queue. Meant to be used by ConnectionPool + # with which it shares a Monitor. class Queue def initialize(lock = Monitor.new) @lock = lock @@ -175,7 +170,7 @@ module ActiveRecord # Removes and returns the head of the queue if possible, or +nil+. def remove - @queue.shift + @queue.pop end # Remove and return the head the queue if the number of @@ -283,12 +278,12 @@ module ActiveRecord end end - # Every +frequency+ seconds, the reaper will call +reap+ on +pool+. - # A reaper instantiated with a +nil+ frequency will never reap the - # connection pool. + # Every +frequency+ seconds, the reaper will call +reap+ and +flush+ on + # +pool+. A reaper instantiated with a zero frequency will never reap + # the connection pool. # - # Configure the frequency by setting "reaping_frequency" in your - # database yaml file. + # Configure the frequency by setting +reaping_frequency+ in your database + # yaml file (default 60 seconds). class Reaper attr_reader :pool, :frequency @@ -298,11 +293,12 @@ module ActiveRecord end def run - return unless frequency + return unless frequency && frequency > 0 Thread.new(frequency, pool) { |t, p| loop do sleep t p.reap + p.flush end } end @@ -326,6 +322,10 @@ module ActiveRecord @spec = spec @checkout_timeout = (spec.config[:checkout_timeout] && spec.config[:checkout_timeout].to_f) || 5 + if @idle_timeout = spec.config.fetch(:idle_timeout, 300) + @idle_timeout = @idle_timeout.to_f + @idle_timeout = nil if @idle_timeout <= 0 + end # default max pool size to 5 @size = (spec.config[:pool] && spec.config[:pool].to_i) || 5 @@ -356,7 +356,10 @@ module ActiveRecord @lock_thread = false - @reaper = Reaper.new(self, spec.config[:reaping_frequency] && spec.config[:reaping_frequency].to_f) + # +reaping_frequency+ is configurable mostly for historical reasons, but it could + # also be useful if someone wants a very low +idle_timeout+. + reaping_frequency = spec.config.fetch(:reaping_frequency, 60) + @reaper = Reaper.new(self, reaping_frequency && reaping_frequency.to_f) @reaper.run end @@ -450,6 +453,21 @@ module ActiveRecord disconnect(false) end + # Discards all connections in the pool (even if they're currently + # leased!), along with the pool itself. Any further interaction with the + # pool (except #spec and #schema_cache) is undefined. + # + # See AbstractAdapter#discard! + def discard! # :nodoc: + synchronize do + return if @connections.nil? # already discarded + @connections.each do |conn| + conn.discard! + end + @connections = @available = @thread_cached_conns = nil + end + end + # Clears the cache which maps classes and re-connects connections that # require reloading. # @@ -575,6 +593,35 @@ module ActiveRecord end end + # Disconnect all connections that have been idle for at least + # +minimum_idle+ seconds. Connections currently checked out, or that were + # checked in less than +minimum_idle+ seconds ago, are unaffected. + def flush(minimum_idle = @idle_timeout) + return if minimum_idle.nil? + + idle_connections = synchronize do + @connections.select do |conn| + !conn.in_use? && conn.seconds_idle >= minimum_idle + end.each do |conn| + conn.lease + + @available.delete conn + @connections.delete conn + end + end + + idle_connections.each do |conn| + conn.disconnect! + end + end + + # Disconnect all currently idle connections. Connections currently checked + # out are unaffected. + def flush! + reap + flush(-1) + end + def num_waiting_in_queue # :nodoc: @available.num_waiting end @@ -866,11 +913,31 @@ module ActiveRecord # about the model. The model needs to pass a specification name to the handler, # in order to look up the correct connection pool. class ConnectionHandler + def self.unowned_pool_finalizer(pid_map) # :nodoc: + lambda do |_| + discard_unowned_pools(pid_map) + end + end + + def self.discard_unowned_pools(pid_map) # :nodoc: + pid_map.each do |pid, pools| + pools.values.compact.each(&:discard!) unless pid == Process.pid + end + end + def initialize # These caches are keyed by spec.name (ConnectionSpecification#name). @owner_to_pool = Concurrent::Map.new(initial_capacity: 2) do |h, k| + # Discard the parent's connection pools immediately; we have no need + # of them + ConnectionHandler.discard_unowned_pools(h) + h[k] = Concurrent::Map.new(initial_capacity: 2) end + + # Backup finalizer: if the forked child never needed a pool, the above + # early discard has not occurred + ObjectSpace.define_finalizer self, ConnectionHandler.unowned_pool_finalizer(@owner_to_pool) end def connection_pool_list @@ -924,6 +991,13 @@ module ActiveRecord connection_pool_list.each(&:disconnect!) end + # Disconnects all currently idle connections. + # + # See ConnectionPool#flush! for details. + def flush_idle_connections! + connection_pool_list.each(&:flush!) + end + # Locate the connection of the nearest super class. This can be an # active or defined connection: if it is the latter, it will be # opened and set as the active connection for the class it was defined diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb index be2f625d74..0594b4b485 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb @@ -6,7 +6,7 @@ module ActiveRecord # this type are typically created and returned by methods in database # adapters. e.g. ActiveRecord::ConnectionAdapters::MySQL::SchemaStatements#indexes class IndexDefinition # :nodoc: - attr_reader :table, :name, :unique, :columns, :lengths, :orders, :where, :type, :using, :comment + attr_reader :table, :name, :unique, :columns, :lengths, :orders, :opclasses, :where, :type, :using, :comment def initialize( table, name, @@ -14,6 +14,7 @@ module ActiveRecord columns = [], lengths: {}, orders: {}, + opclasses: {}, where: nil, type: nil, using: nil, @@ -23,13 +24,23 @@ module ActiveRecord @name = name @unique = unique @columns = columns - @lengths = lengths - @orders = orders + @lengths = concise_options(lengths) + @orders = concise_options(orders) + @opclasses = concise_options(opclasses) @where = where @type = type @using = using @comment = comment end + + private + def concise_options(options) + if columns.size == options.size && options.values.uniq.size == 1 + options.values.first + else + options + end + end end # Abstract representation of a column definition. Instances of this type @@ -85,6 +96,11 @@ module ActiveRecord options[:primary_key] != default_primary_key end + def validate? + options.fetch(:validate, true) + end + alias validated? validate? + def defined_for?(to_table_ord = nil, to_table: nil, **options) if to_table_ord self.to_table == to_table_ord.to_s @@ -204,6 +220,7 @@ module ActiveRecord :decimal, :float, :integer, + :json, :string, :text, :time, diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb index 9b7345f7c3..4f58b0242c 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -600,7 +600,7 @@ module ActiveRecord # to provide these in a migration's +change+ method so it can be reverted. # In that case, +type+ and +options+ will be used by #add_column. def remove_column(table_name, column_name, type = nil, options = {}) - execute "ALTER TABLE #{quote_table_name(table_name)} DROP #{quote_column_name(column_name)}" + execute "ALTER TABLE #{quote_table_name(table_name)} #{remove_column_for_alter(table_name, column_name, type, options)}" end # Changes the column's definition according to the new options. @@ -738,6 +738,28 @@ module ActiveRecord # # Note: only supported by PostgreSQL and MySQL # + # ====== Creating an index with a specific operator class + # + # add_index(:developers, :name, using: 'gist', opclass: :gist_trgm_ops) + # + # generates: + # + # CREATE INDEX developers_on_name ON developers USING gist (name gist_trgm_ops) -- PostgreSQL + # + # add_index(:developers, [:name, :city], using: 'gist', opclass: { city: :gist_trgm_ops }) + # + # generates: + # + # CREATE INDEX developers_on_name_and_city ON developers USING gist (name, city gist_trgm_ops) -- PostgreSQL + # + # add_index(:developers, [:name, :city], using: 'gist', opclass: :gist_trgm_ops) + # + # generates: + # + # CREATE INDEX developers_on_name_and_city ON developers USING gist (name gist_trgm_ops, city gist_trgm_ops) -- PostgreSQL + # + # Note: only supported by PostgreSQL + # # ====== Creating an index with a specific type # # add_index(:developers, :name, type: :fulltext) @@ -942,6 +964,8 @@ module ActiveRecord # Action that happens <tt>ON DELETE</tt>. Valid values are +:nullify+, +:cascade+ and +:restrict+ # [<tt>:on_update</tt>] # Action that happens <tt>ON UPDATE</tt>. Valid values are +:nullify+, +:cascade+ and +:restrict+ + # [<tt>:validate</tt>] + # (Postgres only) Specify whether or not the constraint should be validated. Defaults to +true+. def add_foreign_key(from_table, to_table, options = {}) return unless supports_foreign_keys? @@ -1120,7 +1144,7 @@ module ActiveRecord def add_index_options(table_name, column_name, comment: nil, **options) # :nodoc: column_names = index_column_names(column_name) - options.assert_valid_keys(:unique, :order, :name, :where, :length, :internal, :using, :algorithm, :type) + options.assert_valid_keys(:unique, :order, :name, :where, :length, :internal, :using, :algorithm, :type, :opclass) index_type = options[:type].to_s if options.key?(:type) index_type ||= options[:unique] ? "UNIQUE" : "" @@ -1173,20 +1197,22 @@ module ActiveRecord end def add_index_sort_order(quoted_columns, **options) - if order = options[:order] - case order - when Hash - order = order.symbolize_keys - quoted_columns.each { |name, column| column << " #{order[name].upcase}" if order[name].present? } - when String - quoted_columns.each { |name, column| column << " #{order.upcase}" if order.present? } - end + orders = options_for_index_columns(options[:order]) + quoted_columns.each do |name, column| + column << " #{orders[name].upcase}" if orders[name].present? end + end - quoted_columns + def options_for_index_columns(options) + if options.is_a?(Hash) + options.symbolize_keys + else + Hash.new { |hash, column| hash[column] = options } + end end - # Overridden by the MySQL adapter for supporting index lengths + # Overridden by the MySQL adapter for supporting index lengths and by + # the PostgreSQL adapter for supporting operator classes. def add_options_for_index_columns(quoted_columns, **options) if supports_index_sort_order? quoted_columns = add_index_sort_order(quoted_columns, options) @@ -1340,6 +1366,20 @@ module ActiveRecord options.is_a?(Hash) && options.key?(:name) && options.except(:name, :algorithm).empty? end + def add_column_for_alter(table_name, column_name, type, options = {}) + td = create_table_definition(table_name) + cd = td.new_column_definition(column_name, type, options) + schema_creation.accept(AddColumnDefinition.new(cd)) + end + + def remove_column_for_alter(table_name, column_name, type = nil, options = {}) + "DROP COLUMN #{quote_column_name(column_name)}" + end + + def remove_columns_for_alter(table_name, *column_names) + column_names.map { |column_name| remove_column_for_alter(table_name, column_name) } + end + def insert_versions_sql(versions) sm_table = quote_table_name(ActiveRecord::SchemaMigration.table_name) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb index 147e16e9fa..d9ac8db6a8 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb @@ -240,7 +240,7 @@ module ActiveRecord rollback_transaction if transaction else begin - commit_transaction + commit_transaction if transaction rescue Exception rollback_transaction(transaction) unless transaction.state.completed? raise diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index e3aab8dad8..fc80d332f9 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -require "active_record/type" require "active_record/connection_adapters/determine_if_preparable_visitor" require "active_record/connection_adapters/schema_cache" require "active_record/connection_adapters/sql_type_metadata" require "active_record/connection_adapters/abstract/schema_dumper" require "active_record/connection_adapters/abstract/schema_creation" +require "active_support/concurrency/load_interlock_aware_monitor" require "arel/collectors/bind" require "arel/collectors/composite" require "arel/collectors/sql_string" @@ -105,10 +105,11 @@ module ActiveRecord @logger = logger @config = config @pool = nil + @idle_since = Concurrent.monotonic_time @schema_cache = SchemaCache.new self @quoted_column_names, @quoted_table_names = {}, {} @visitor = arel_visitor - @lock = Monitor.new + @lock = ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new if self.class.type_cast_config_to_boolean(config.fetch(:prepared_statements) { true }) @prepared_statements = true @@ -164,6 +165,7 @@ module ActiveRecord "Current thread: #{Thread.current}." end + @idle_since = Concurrent.monotonic_time @owner = nil else raise ActiveRecordError, "Cannot expire connection, it is not currently leased." @@ -183,6 +185,12 @@ module ActiveRecord end end + # Seconds since this connection was returned to the pool + def seconds_idle # :nodoc: + return 0 if in_use? + Concurrent.monotonic_time - @idle_since + end + def unprepared_statement old_prepared_statements, @prepared_statements = @prepared_statements, false yield @@ -264,6 +272,11 @@ module ActiveRecord false end + # Does this adapter support creating invalid constraints? + def supports_validate_constraints? + false + end + # Does this adapter support creating foreign key constraints # in the same statement as creating the table? def supports_foreign_keys_in_create? @@ -367,6 +380,19 @@ module ActiveRecord reset_transaction end + # Immediately forget this connection ever existed. Unlike disconnect!, + # this will not communicate with the server. + # + # After calling this method, the behavior of all other methods becomes + # undefined. This is called internally just before a forked process gets + # rid of a connection that belonged to its parent. + def discard! + # This should be overridden by concrete adapters. + # + # Prevent @connection's finalizer from touching the socket, or + # otherwise communicating with its server, when it is collected. + end + # Reset the state of this connection, directing the DBMS to clear # transactions and other connection-related server-side state. Usually a # database-dependent operation. diff --git a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb index bfec6fb784..479131caad 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb @@ -44,7 +44,7 @@ module ActiveRecord json: { name: "json" }, } - class StatementPool < ConnectionAdapters::StatementPool + class StatementPool < ConnectionAdapters::StatementPool # :nodoc: private def dealloc(stmt) stmt[:stmt].close end @@ -299,7 +299,7 @@ module ActiveRecord def bulk_change_table(table_name, operations) #:nodoc: sqls = operations.flat_map do |command, args| table, arguments = args.shift, args - method = :"#{command}_sql" + method = :"#{command}_for_alter" if respond_to?(method, true) send(method, table, *arguments) @@ -372,11 +372,11 @@ module ActiveRecord end def change_column(table_name, column_name, type, options = {}) #:nodoc: - execute("ALTER TABLE #{quote_table_name(table_name)} #{change_column_sql(table_name, column_name, type, options)}") + execute("ALTER TABLE #{quote_table_name(table_name)} #{change_column_for_alter(table_name, column_name, type, options)}") end def rename_column(table_name, column_name, new_column_name) #:nodoc: - execute("ALTER TABLE #{quote_table_name(table_name)} #{rename_column_sql(table_name, column_name, new_column_name)}") + execute("ALTER TABLE #{quote_table_name(table_name)} #{rename_column_for_alter(table_name, column_name, new_column_name)}") rename_column_indexes(table_name, column_name, new_column_name) end @@ -605,25 +605,6 @@ module ActiveRecord end end - def add_index_length(quoted_columns, **options) - if length = options[:length] - case length - when Hash - length = length.symbolize_keys - quoted_columns.each { |name, column| column << "(#{length[name]})" if length[name].present? } - when Integer - quoted_columns.each { |name, column| column << "(#{length})" } - end - end - - quoted_columns - end - - def add_options_for_index_columns(quoted_columns, **options) - quoted_columns = add_index_length(quoted_columns, options) - super - end - # See https://dev.mysql.com/doc/refman/5.7/en/error-messages-server.html ER_DUP_ENTRY = 1062 ER_NOT_NULL_VIOLATION = 1048 @@ -635,6 +616,8 @@ module ActiveRecord ER_CANNOT_ADD_FOREIGN = 1215 ER_CANNOT_CREATE_TABLE = 1005 ER_LOCK_WAIT_TIMEOUT = 1205 + ER_QUERY_INTERRUPTED = 1317 + ER_QUERY_TIMEOUT = 3024 def translate_exception(exception, message) case error_number(exception) @@ -659,19 +642,17 @@ module ActiveRecord when ER_LOCK_DEADLOCK Deadlocked.new(message) when ER_LOCK_WAIT_TIMEOUT - TransactionTimeout.new(message) + LockWaitTimeout.new(message) + when ER_QUERY_TIMEOUT + StatementTimeout.new(message) + when ER_QUERY_INTERRUPTED + QueryCanceled.new(message) else super end end - def add_column_sql(table_name, column_name, type, options = {}) - td = create_table_definition(table_name) - cd = td.new_column_definition(column_name, type, options) - schema_creation.accept(AddColumnDefinition.new(cd)) - end - - def change_column_sql(table_name, column_name, type, options = {}) + def change_column_for_alter(table_name, column_name, type, options = {}) column = column_for(table_name, column_name) type ||= column.sql_type @@ -692,7 +673,7 @@ module ActiveRecord schema_creation.accept(ChangeColumnDefinition.new(cd, column.name)) end - def rename_column_sql(table_name, column_name, new_column_name) + def rename_column_for_alter(table_name, column_name, new_column_name) column = column_for(table_name, column_name) options = { default: column.default, @@ -706,31 +687,23 @@ module ActiveRecord schema_creation.accept(ChangeColumnDefinition.new(cd, column.name)) end - def remove_column_sql(table_name, column_name, type = nil, options = {}) - "DROP #{quote_column_name(column_name)}" - end - - def remove_columns_sql(table_name, *column_names) - column_names.map { |column_name| remove_column_sql(table_name, column_name) } - end - - def add_index_sql(table_name, column_name, options = {}) + def add_index_for_alter(table_name, column_name, options = {}) index_name, index_type, index_columns, _, index_algorithm, index_using = add_index_options(table_name, column_name, options) index_algorithm[0, 0] = ", " if index_algorithm.present? "ADD #{index_type} INDEX #{quote_column_name(index_name)} #{index_using} (#{index_columns})#{index_algorithm}" end - def remove_index_sql(table_name, options = {}) + def remove_index_for_alter(table_name, options = {}) index_name = index_name_for_remove(table_name, options) "DROP INDEX #{quote_column_name(index_name)}" end - def add_timestamps_sql(table_name, options = {}) - [add_column_sql(table_name, :created_at, :datetime, options), add_column_sql(table_name, :updated_at, :datetime, options)] + def add_timestamps_for_alter(table_name, options = {}) + [add_column_for_alter(table_name, :created_at, :datetime, options), add_column_for_alter(table_name, :updated_at, :datetime, options)] end - def remove_timestamps_sql(table_name, options = {}) - [remove_column_sql(table_name, :updated_at), remove_column_sql(table_name, :created_at)] + def remove_timestamps_for_alter(table_name, options = {}) + [remove_column_for_alter(table_name, :updated_at), remove_column_for_alter(table_name, :created_at)] end # MySQL is too stupid to create a temporary table for use subquery, so we have diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb index da25e4863c..2ed4ad16ae 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb @@ -32,10 +32,6 @@ module ActiveRecord args.each { |name| column(name, :longtext, options) } end - def json(*args, **options) - args.each { |name| column(name, :json, options) } - end - def unsigned_integer(*args, **options) args.each { |name| column(name, :unsigned_integer, options) } end diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb index a15c7d1787..ce50590651 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb @@ -22,23 +22,26 @@ module ActiveRecord index_using = mysql_index_type end - indexes << IndexDefinition.new( + indexes << [ row[:Table], row[:Key_name], row[:Non_unique].to_i == 0, + [], + lengths: {}, + orders: {}, type: index_type, using: index_using, comment: row[:Index_comment].presence - ) + ] end - indexes.last.columns << row[:Column_name] - indexes.last.lengths.merge!(row[:Column_name] => row[:Sub_part].to_i) if row[:Sub_part] - indexes.last.orders.merge!(row[:Column_name] => :desc) if row[:Collation] == "D" + indexes.last[-2] << row[:Column_name] + indexes.last[-1][:lengths].merge!(row[:Column_name] => row[:Sub_part].to_i) if row[:Sub_part] + indexes.last[-1][:orders].merge!(row[:Column_name] => :desc) if row[:Collation] == "D" end end - indexes + indexes.map { |index| IndexDefinition.new(*index) } end def remove_column(table_name, column_name, type = nil, options = {}) @@ -103,6 +106,18 @@ module ActiveRecord super unless specifier == "RESTRICT" end + def add_index_length(quoted_columns, **options) + lengths = options_for_index_columns(options[:length]) + quoted_columns.each do |name, column| + column << "(#{lengths[name]})" if lengths[name].present? + end + end + + def add_options_for_index_columns(quoted_columns, **options) + quoted_columns = add_index_length(quoted_columns, options) + super + end + def data_source_sql(name = nil, type: nil) scope = quoted_scope(name, type: type) diff --git a/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb b/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb index 8de582fee1..bfdc7995f0 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb @@ -3,9 +3,8 @@ require "active_record/connection_adapters/abstract_mysql_adapter" require "active_record/connection_adapters/mysql/database_statements" -gem "mysql2", ">= 0.3.18", "< 0.5" +gem "mysql2", "~> 0.4.4" require "mysql2" -raise "mysql2 0.4.3 is not supported. Please upgrade to 0.4.4+" if Mysql2::VERSION == "0.4.3" module ActiveRecord module ConnectionHandling # :nodoc: @@ -105,6 +104,11 @@ module ActiveRecord @connection.close end + def discard! # :nodoc: + @connection.automatic_close = false + @connection = nil + end + private def connect diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb index 59f661da25..8e381a92cf 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb @@ -5,6 +5,18 @@ module ActiveRecord module PostgreSQL class SchemaCreation < AbstractAdapter::SchemaCreation # :nodoc: private + def visit_AlterTable(o) + super << o.constraint_validations.map { |fk| visit_ValidateConstraint fk }.join(" ") + end + + def visit_AddForeignKey(o) + super.dup.tap { |sql| sql << " NOT VALID" unless o.validate? } + end + + def visit_ValidateConstraint(name) + "VALIDATE CONSTRAINT #{quote_column_name(name)}" + end + def add_column_options!(sql, options) if options[:collation] sql << " COLLATE \"#{options[:collation]}\"" diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb index 75622eb304..6047217fcd 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb @@ -95,10 +95,6 @@ module ActiveRecord args.each { |name| column(name, :int8range, options) } end - def json(*args, **options) - args.each { |name| column(name, :json, options) } - end - def jsonb(*args, **options) args.each { |name| column(name, :jsonb, options) } end @@ -192,6 +188,19 @@ module ActiveRecord class Table < ActiveRecord::ConnectionAdapters::Table include ColumnMethods end + + class AlterTable < ActiveRecord::ConnectionAdapters::AlterTable + attr_reader :constraint_validations + + def initialize(td) + super + @constraint_validations = [] + end + + def validate_constraint(name) + @constraint_validations << name + end + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb index 846e721983..bf5fbb30e1 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb @@ -87,10 +87,7 @@ module ActiveRecord result = query(<<-SQL, "SCHEMA") SELECT distinct i.relname, d.indisunique, d.indkey, pg_get_indexdef(d.indexrelid), t.oid, - pg_catalog.obj_description(i.oid, 'pg_class') AS comment, - (SELECT COUNT(*) FROM pg_opclass o - JOIN (SELECT unnest(string_to_array(d.indclass::text, ' '))::int oid) c - ON o.oid = c.oid WHERE o.opcdefault = 'f') + pg_catalog.obj_description(i.oid, 'pg_class') AS comment FROM pg_class t INNER JOIN pg_index d ON t.oid = d.indrelid INNER JOIN pg_class i ON d.indexrelid = i.oid @@ -109,11 +106,13 @@ module ActiveRecord inddef = row[3] oid = row[4] comment = row[5] - opclass = row[6] using, expressions, where = inddef.scan(/ USING (\w+?) \((.+?)\)(?: WHERE (.+))?\z/).flatten - if indkey.include?(0) || opclass > 0 + orders = {} + opclasses = {} + + if indkey.include?(0) columns = expressions else columns = Hash[query(<<-SQL.strip_heredoc, "SCHEMA")].values_at(*indkey).compact @@ -123,10 +122,12 @@ module ActiveRecord AND a.attnum IN (#{indkey.join(",")}) SQL - # add info on sort order for columns (only desc order is explicitly specified, asc is the default) - orders = Hash[ - expressions.scan(/(\w+) DESC/).flatten.map { |order_column| [order_column, :desc] } - ] + # add info on sort order (only desc order is explicitly specified, asc is the default) + # and non-default opclasses + expressions.scan(/(\w+)(?: (?!DESC)(\w+))?(?: (DESC))?/).each do |column, opclass, desc| + opclasses[column] = opclass.to_sym if opclass + orders[column] = :desc if desc + end end IndexDefinition.new( @@ -135,6 +136,7 @@ module ActiveRecord unique, columns, orders: orders, + opclasses: opclasses, where: where, using: using.to_sym, comment: comment.presence @@ -392,50 +394,23 @@ module ActiveRecord def change_column(table_name, column_name, type, options = {}) #:nodoc: clear_cache! - quoted_table_name = quote_table_name(table_name) - quoted_column_name = quote_column_name(column_name) - sql_type = type_to_sql(type, options) - sql = "ALTER TABLE #{quoted_table_name} ALTER COLUMN #{quoted_column_name} TYPE #{sql_type}".dup - if options[:collation] - sql << " COLLATE \"#{options[:collation]}\"" - end - if options[:using] - sql << " USING #{options[:using]}" - elsif options[:cast_as] - cast_as_type = type_to_sql(options[:cast_as], options) - sql << " USING CAST(#{quoted_column_name} AS #{cast_as_type})" - end - execute sql - - change_column_default(table_name, column_name, options[:default]) if options.key?(:default) - change_column_null(table_name, column_name, options[:null], options[:default]) if options.key?(:null) - change_column_comment(table_name, column_name, options[:comment]) if options.key?(:comment) + sqls, procs = change_column_for_alter(table_name, column_name, type, options) + execute "ALTER TABLE #{quote_table_name(table_name)} #{sqls.join(", ")}" + procs.each(&:call) end # Changes the default value of a table column. def change_column_default(table_name, column_name, default_or_changes) # :nodoc: - clear_cache! - column = column_for(table_name, column_name) - return unless column - - default = extract_new_default_value(default_or_changes) - alter_column_query = "ALTER TABLE #{quote_table_name(table_name)} ALTER COLUMN #{quote_column_name(column_name)} %s" - if default.nil? - # <tt>DEFAULT NULL</tt> results in the same behavior as <tt>DROP DEFAULT</tt>. However, PostgreSQL will - # cast the default to the columns type, which leaves us with a default like "default NULL::character varying". - execute alter_column_query % "DROP DEFAULT" - else - execute alter_column_query % "SET DEFAULT #{quote_default_expression(default, column)}" - end + execute "ALTER TABLE #{quote_table_name(table_name)} #{change_column_default_for_alter(table_name, column_name, default_or_changes)}" end def change_column_null(table_name, column_name, null, default = nil) #:nodoc: clear_cache! unless null || default.nil? column = column_for(table_name, column_name) - execute("UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)}=#{quote_default_expression(default, column)} WHERE #{quote_column_name(column_name)} IS NULL") if column + execute "UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)}=#{quote_default_expression(default, column)} WHERE #{quote_column_name(column_name)} IS NULL" if column end - execute("ALTER TABLE #{quote_table_name(table_name)} ALTER #{quote_column_name(column_name)} #{null ? 'DROP' : 'SET'} NOT NULL") + execute "ALTER TABLE #{quote_table_name(table_name)} #{change_column_null_for_alter(table_name, column_name, null, default)}" end # Adds comment for given table column or drops it if +comment+ is a +nil+ @@ -458,8 +433,8 @@ module ActiveRecord end def add_index(table_name, column_name, options = {}) #:nodoc: - index_name, index_type, index_columns, index_options, index_algorithm, index_using, comment = add_index_options(table_name, column_name, options) - execute("CREATE #{index_type} INDEX #{index_algorithm} #{quote_column_name(index_name)} ON #{quote_table_name(table_name)} #{index_using} (#{index_columns})#{index_options}").tap do + index_name, index_type, index_columns_and_opclasses, index_options, index_algorithm, index_using, comment = add_index_options(table_name, column_name, options) + execute("CREATE #{index_type} INDEX #{index_algorithm} #{quote_column_name(index_name)} ON #{quote_table_name(table_name)} #{index_using} (#{index_columns_and_opclasses})#{index_options}").tap do execute "COMMENT ON INDEX #{quote_column_name(index_name)} IS #{quote(comment)}" if comment end end @@ -499,7 +474,7 @@ module ActiveRecord def foreign_keys(table_name) scope = quoted_scope(table_name) fk_info = exec_query(<<-SQL.strip_heredoc, "SCHEMA") - SELECT t2.oid::regclass::text AS to_table, a1.attname AS column, a2.attname AS primary_key, c.conname AS name, c.confupdtype AS on_update, c.confdeltype AS on_delete + SELECT t2.oid::regclass::text AS to_table, a1.attname AS column, a2.attname AS primary_key, c.conname AS name, c.confupdtype AS on_update, c.confdeltype AS on_delete, c.convalidated AS valid FROM pg_constraint c JOIN pg_class t1 ON c.conrelid = t1.oid JOIN pg_class t2 ON c.confrelid = t2.oid @@ -521,6 +496,7 @@ module ActiveRecord options[:on_delete] = extract_foreign_key_action(row["on_delete"]) options[:on_update] = extract_foreign_key_action(row["on_update"]) + options[:validate] = row["valid"] ForeignKeyDefinition.new(table_name, row["to_table"], options) end @@ -581,6 +557,43 @@ module ActiveRecord PostgreSQL::SchemaDumper.create(self, options) end + # Validates the given constraint. + # + # Validates the constraint named +constraint_name+ on +accounts+. + # + # validate_constraint :accounts, :constraint_name + def validate_constraint(table_name, constraint_name) + return unless supports_validate_constraints? + + at = create_alter_table table_name + at.validate_constraint constraint_name + + execute schema_creation.accept(at) + end + + # Validates the given foreign key. + # + # Validates the foreign key on +accounts.branch_id+. + # + # validate_foreign_key :accounts, :branches + # + # Validates the foreign key on +accounts.owner_id+. + # + # validate_foreign_key :accounts, column: :owner_id + # + # Validates the foreign key named +special_fk_name+ on the +accounts+ table. + # + # validate_foreign_key :accounts, name: :special_fk_name + # + # The +options+ hash accepts the same keys as SchemaStatements#add_foreign_key. + def validate_foreign_key(from_table, options_or_to_table = {}) + return unless supports_validate_constraints? + + fk_name_to_validate = foreign_key_for!(from_table, options_or_to_table).name + + validate_constraint from_table, fk_name_to_validate + end + private def schema_creation PostgreSQL::SchemaCreation.new(self) @@ -590,6 +603,10 @@ module ActiveRecord PostgreSQL::TableDefinition.new(*args) end + def create_alter_table(name) + PostgreSQL::AlterTable.new create_table_definition(name) + end + def new_column_from_field(table_name, field) column_name, type, default, notnull, oid, fmod, collation, comment = field type_metadata = fetch_type_metadata(column_name, type, oid.to_i, fmod.to_i) @@ -629,6 +646,66 @@ module ActiveRecord end end + def change_column_sql(table_name, column_name, type, options = {}) + quoted_column_name = quote_column_name(column_name) + sql_type = type_to_sql(type, options) + sql = "ALTER COLUMN #{quoted_column_name} TYPE #{sql_type}".dup + if options[:collation] + sql << " COLLATE \"#{options[:collation]}\"" + end + if options[:using] + sql << " USING #{options[:using]}" + elsif options[:cast_as] + cast_as_type = type_to_sql(options[:cast_as], options) + sql << " USING CAST(#{quoted_column_name} AS #{cast_as_type})" + end + + sql + end + + def change_column_for_alter(table_name, column_name, type, options = {}) + sqls = [change_column_sql(table_name, column_name, type, options)] + procs = [] + sqls << change_column_default_for_alter(table_name, column_name, options[:default]) if options.key?(:default) + sqls << change_column_null_for_alter(table_name, column_name, options[:null], options[:default]) if options.key?(:null) + procs << Proc.new { change_column_comment(table_name, column_name, options[:comment]) } if options.key?(:comment) + + [sqls, procs] + end + + + # Changes the default value of a table column. + def change_column_default_for_alter(table_name, column_name, default_or_changes) # :nodoc: + column = column_for(table_name, column_name) + return unless column + + default = extract_new_default_value(default_or_changes) + alter_column_query = "ALTER COLUMN #{quote_column_name(column_name)} %s" + if default.nil? + # <tt>DEFAULT NULL</tt> results in the same behavior as <tt>DROP DEFAULT</tt>. However, PostgreSQL will + # cast the default to the columns type, which leaves us with a default like "default NULL::character varying". + alter_column_query % "DROP DEFAULT" + else + alter_column_query % "SET DEFAULT #{quote_default_expression(default, column)}" + end + end + + def change_column_null_for_alter(table_name, column_name, null, default = nil) #:nodoc: + "ALTER #{quote_column_name(column_name)} #{null ? 'DROP' : 'SET'} NOT NULL" + end + + def add_index_opclass(quoted_columns, **options) + opclasses = options_for_index_columns(options[:opclass]) + quoted_columns.each do |name, column| + column << " #{opclasses[name]}" if opclasses[name].present? + end + end + + def add_options_for_index_columns(quoted_columns, **options) + quoted_columns = add_index_opclass(quoted_columns, options) + super + end + def data_source_sql(name = nil, type: nil) scope = quoted_scope(name, type: type) scope[:type] ||= "'r','v','m'" # (r)elation/table, (v)iew, (m)aterialized view diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 2c3c1df2a9..23fc69d649 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -142,6 +142,10 @@ module ActiveRecord true end + def supports_validate_constraints? + true + end + def supports_views? true end @@ -166,7 +170,7 @@ module ActiveRecord { concurrently: "CONCURRENTLY" } end - class StatementPool < ConnectionAdapters::StatementPool + class StatementPool < ConnectionAdapters::StatementPool # :nodoc: def initialize(connection, max) super(max) @connection = connection @@ -182,7 +186,6 @@ module ActiveRecord end private - def dealloc(key) @connection.query "DEALLOCATE #{key}" if connection_active? rescue PG::Error @@ -273,6 +276,11 @@ module ActiveRecord end end + def discard! # :nodoc: + @connection.socket_io.reopen(IO::NULL) + @connection = nil + end + def native_database_types #:nodoc: NATIVE_DATABASE_TYPES end @@ -382,7 +390,6 @@ module ActiveRecord end private - # See https://www.postgresql.org/docs/current/static/errcodes-appendix.html VALUE_LIMIT_VIOLATION = "22001" NUMERIC_VALUE_OUT_OF_RANGE = "22003" @@ -391,6 +398,8 @@ module ActiveRecord UNIQUE_VIOLATION = "23505" SERIALIZATION_FAILURE = "40001" DEADLOCK_DETECTED = "40P01" + LOCK_NOT_AVAILABLE = "55P03" + QUERY_CANCELED = "57014" def translate_exception(exception, message) return exception unless exception.respond_to?(:result) @@ -410,6 +419,10 @@ module ActiveRecord SerializationFailure.new(message) when DEADLOCK_DETECTED Deadlocked.new(message) + when LOCK_NOT_AVAILABLE + LockWaitTimeout.new(message) + when QUERY_CANCELED + QueryCanceled.new(message) else super end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index 670afa3684..65aba20052 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -70,7 +70,8 @@ module ActiveRecord time: { name: "time" }, date: { name: "date" }, binary: { name: "blob" }, - boolean: { name: "boolean" } + boolean: { name: "boolean" }, + json: { name: "json" }, } ## @@ -90,9 +91,8 @@ module ActiveRecord # Rails.application.config.active_record.sqlite3.represent_boolean_as_integer = true class_attribute :represent_boolean_as_integer, default: false - class StatementPool < ConnectionAdapters::StatementPool + class StatementPool < ConnectionAdapters::StatementPool # :nodoc: private - def dealloc(stmt) stmt[:stmt].close unless stmt[:stmt].closed? end @@ -135,6 +135,10 @@ module ActiveRecord true end + def supports_json? + true + end + def supports_multi_insert? sqlite_version >= "3.7.11" end @@ -370,6 +374,10 @@ module ActiveRecord end private + def initialize_type_map(m = type_map) + super + register_class_with_limit m, %r(int)i, SQLite3Integer + end def table_structure(table_name) structure = exec_query("PRAGMA table_info(#{quote_table_name(table_name)})", "SCHEMA") @@ -396,10 +404,11 @@ module ActiveRecord def copy_table(from, to, options = {}) from_primary_key = primary_key(from) + from_primary_key_column = columns(from).select { |column| column.name == from_primary_key }.first options[:id] = false create_table(to, options) do |definition| @definition = definition - @definition.primary_key(from_primary_key) if from_primary_key.present? + @definition.primary_key(from_primary_key, from_primary_key_column.type) if from_primary_key.present? columns(from).each do |column| column_name = options[:rename] ? (options[:rename][column.name] || @@ -423,6 +432,9 @@ module ActiveRecord def copy_table_indexes(from, to, rename = {}) indexes(from).each do |index| name = index.name + # indexes sqlite creates for internal use start with `sqlite_` and + # don't need to be copied + next if name.starts_with?("sqlite_") if to == "a#{from}" name = "t#{name}" elsif from == "a#{to}" @@ -525,6 +537,17 @@ module ActiveRecord def configure_connection execute("PRAGMA foreign_keys = ON", "SCHEMA") end + + class SQLite3Integer < Type::Integer # :nodoc: + private + def _limit + # INTEGER storage class can be stored 8 bytes value. + # See https://www.sqlite.org/datatype3.html#storage_classes_and_datatypes + limit || 8 + end + end + + ActiveRecord::Type.register(:integer, SQLite3Integer, adapter: :sqlite3) end ActiveSupport.run_load_hooks(:active_record_sqlite3adapter, SQLite3Adapter) end diff --git a/activerecord/lib/active_record/connection_handling.rb b/activerecord/lib/active_record/connection_handling.rb index 9a47edfba4..88d28dc52a 100644 --- a/activerecord/lib/active_record/connection_handling.rb +++ b/activerecord/lib/active_record/connection_handling.rb @@ -140,6 +140,6 @@ module ActiveRecord end delegate :clear_active_connections!, :clear_reloadable_connections!, - :clear_all_connections!, to: :connection_handler + :clear_all_connections!, :flush_idle_connections!, to: :connection_handler end end diff --git a/activerecord/lib/active_record/core.rb b/activerecord/lib/active_record/core.rb index 0f7a503c90..481159e501 100644 --- a/activerecord/lib/active_record/core.rb +++ b/activerecord/lib/active_record/core.rb @@ -76,6 +76,14 @@ module ActiveRecord # scope being ignored is error-worthy, rather than a warning. mattr_accessor :error_on_ignored_order, instance_writer: false, default: false + # :singleton-method: + # Specify the behavior for unsafe raw query methods. Values are as follows + # deprecated - Warnings are logged when unsafe raw SQL is passed to + # query methods. + # disabled - Unsafe raw SQL passed to query methods results in + # UnknownAttributeReference exception. + mattr_accessor :allow_unsafe_raw_sql, instance_writer: false, default: :deprecated + ## # :singleton-method: # Specify whether or not to use timestamps for migration versions @@ -269,7 +277,8 @@ module ActiveRecord relation = Relation.create(self, arel_table, predicate_builder) if finder_needs_type_condition? && !ignore_default_scope? - relation.where(type_condition).create_with(inheritance_column.to_s => sti_name) + relation.where!(type_condition) + relation.create_with!(inheritance_column.to_s => sti_name) else relation end diff --git a/activerecord/lib/active_record/enum.rb b/activerecord/lib/active_record/enum.rb index f373b98035..1a3e6e4d09 100644 --- a/activerecord/lib/active_record/enum.rb +++ b/activerecord/lib/active_record/enum.rb @@ -221,6 +221,8 @@ module ActiveRecord def detect_enum_conflict!(enum_name, method_name, klass_method = false) if klass_method && dangerous_class_method?(method_name) raise_conflict_error(enum_name, method_name, type: "class") + elsif klass_method && method_defined_within?(method_name, Relation) + raise_conflict_error(enum_name, method_name, type: "class", source: Relation.name) elsif !klass_method && dangerous_attribute_method?(method_name) raise_conflict_error(enum_name, method_name) elsif !klass_method && method_defined_within?(method_name, _enum_methods_module, Module) diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb index 9ef3316393..efcbd44776 100644 --- a/activerecord/lib/active_record/errors.rb +++ b/activerecord/lib/active_record/errors.rb @@ -335,8 +335,40 @@ module ActiveRecord class IrreversibleOrderError < ActiveRecordError end - # TransactionTimeout will be raised when lock wait timeout expires. - # Wait time value is set by innodb_lock_wait_timeout. - class TransactionTimeout < StatementInvalid + # LockWaitTimeout will be raised when lock wait timeout exceeded. + class LockWaitTimeout < StatementInvalid + end + + # StatementTimeout will be raised when statement timeout exceeded. + class StatementTimeout < StatementInvalid + end + + # QueryCanceled will be raised when canceling statement due to user request. + class QueryCanceled < StatementInvalid + end + + # UnknownAttributeReference is raised when an unknown and potentially unsafe + # value is passed to a query method when allow_unsafe_raw_sql is set to + # :disabled. For example, passing a non column name value to a relation's + # #order method might cause this exception. + # + # When working around this exception, caution should be taken to avoid SQL + # injection vulnerabilities when passing user-provided values to query + # methods. Known-safe values can be passed to query methods by wrapping them + # in Arel.sql. + # + # For example, with allow_unsafe_raw_sql set to :disabled, the following + # code would raise this exception: + # + # Post.order("length(title)").first + # + # The desired result can be accomplished by wrapping the known-safe string + # in Arel.sql: + # + # Post.order(Arel.sql("length(title)")).first + # + # Again, such a workaround should *not* be used when passing user-provided + # values, such as request parameters or model attributes to query methods. + class UnknownAttributeReference < ActiveRecordError end end diff --git a/activerecord/lib/active_record/gem_version.rb b/activerecord/lib/active_record/gem_version.rb index 7ccb57b305..7e47dac016 100644 --- a/activerecord/lib/active_record/gem_version.rb +++ b/activerecord/lib/active_record/gem_version.rb @@ -10,7 +10,7 @@ module ActiveRecord MAJOR = 5 MINOR = 2 TINY = 0 - PRE = "alpha" + PRE = "beta2" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activerecord/lib/active_record/legacy_yaml_adapter.rb b/activerecord/lib/active_record/legacy_yaml_adapter.rb index 23644aab8f..ffa095dd94 100644 --- a/activerecord/lib/active_record/legacy_yaml_adapter.rb +++ b/activerecord/lib/active_record/legacy_yaml_adapter.rb @@ -8,7 +8,7 @@ module ActiveRecord case coder["active_record_yaml_version"] when 1, 2 then coder else - if coder["attributes"].is_a?(AttributeSet) + if coder["attributes"].is_a?(ActiveModel::AttributeSet) Rails420.convert(klass, coder) else Rails41.convert(klass, coder) diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index 1485687053..5c10d4ff24 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -581,7 +581,8 @@ module ActiveRecord def load_schema_if_pending! if ActiveRecord::Migrator.needs_migration? || !ActiveRecord::Migrator.any_migrations? # Roundtrip to Rake to allow plugins to hook into database initialization. - FileUtils.cd Rails.root do + root = defined?(ENGINE_ROOT) ? ENGINE_ROOT : Rails.root + FileUtils.cd(root) do current_config = Base.connection_config Base.clear_all_connections! system("bin/rails db:test:prepare") @@ -733,6 +734,24 @@ module ActiveRecord execute_block { yield helper } end + # Used to specify an operation that is only run when migrating up + # (for example, populating a new column with its initial values). + # + # In the following example, the new column +published+ will be given + # the value +true+ for all existing records. + # + # class AddPublishedToPosts < ActiveRecord::Migration[5.2] + # def change + # add_column :posts, :published, :boolean, default: false + # up_only do + # execute "update posts set published = 'true'" + # end + # end + # end + def up_only + execute_block { yield } unless reverting? + end + # Runs the given migration classes. # Last argument can specify options: # - :direction (default is :up) @@ -1026,7 +1045,7 @@ module ActiveRecord new(:up, migrations(migrations_paths), nil) end - def get_all_versions(connection = Base.connection) + def get_all_versions if SchemaMigration.table_exists? SchemaMigration.all_versions.map(&:to_i) else @@ -1034,12 +1053,13 @@ module ActiveRecord end end - def current_version(connection = Base.connection) - get_all_versions(connection).max || 0 + def current_version(connection = nil) + get_all_versions.max || 0 + rescue ActiveRecord::NoDatabaseError end - def needs_migration?(connection = Base.connection) - (migrations(migrations_paths).collect(&:version) - get_all_versions(connection)).size > 0 + def needs_migration?(connection = nil) + (migrations(migrations_paths).collect(&:version) - get_all_versions).size > 0 end def any_migrations? @@ -1224,7 +1244,7 @@ module ActiveRecord # Return true if a valid version is not provided. def invalid_target? - !target && @target_version && @target_version > 0 + @target_version && @target_version != 0 && !target end def execute_migration_in_transaction(migration, direction) diff --git a/activerecord/lib/active_record/migration/command_recorder.rb b/activerecord/lib/active_record/migration/command_recorder.rb index ac7d506fd1..81ef4828f8 100644 --- a/activerecord/lib/active_record/migration/command_recorder.rb +++ b/activerecord/lib/active_record/migration/command_recorder.rb @@ -110,7 +110,7 @@ module ActiveRecord private - module StraightReversions + module StraightReversions # :nodoc: private { transaction: :transaction, execute_block: :execute_block, diff --git a/activerecord/lib/active_record/migration/compatibility.rb b/activerecord/lib/active_record/migration/compatibility.rb index c979aaf0a0..bd8c054c28 100644 --- a/activerecord/lib/active_record/migration/compatibility.rb +++ b/activerecord/lib/active_record/migration/compatibility.rb @@ -16,6 +16,18 @@ module ActiveRecord V5_2 = Current class V5_1 < V5_2 + def change_column(table_name, column_name, type, options = {}) + if adapter_name == "PostgreSQL" + clear_cache! + sql = connection.send(:change_column_sql, table_name, column_name, type, options) + execute "ALTER TABLE #{quote_table_name(table_name)} #{sql}" + change_column_default(table_name, column_name, options[:default]) if options.key?(:default) + change_column_null(table_name, column_name, options[:null], options[:default]) if options.key?(:null) + change_column_comment(table_name, column_name, options[:comment]) if options.key?(:comment) + else + super + end + end end class V5_0 < V5_1 diff --git a/activerecord/lib/active_record/model_schema.rb b/activerecord/lib/active_record/model_schema.rb index bed9400f51..1941d3d5ea 100644 --- a/activerecord/lib/active_record/model_schema.rb +++ b/activerecord/lib/active_record/model_schema.rb @@ -323,11 +323,11 @@ module ActiveRecord end def attributes_builder # :nodoc: - @attributes_builder ||= AttributeSet::Builder.new(attribute_types, primary_key) do |name| - unless columns_hash.key?(name) - _default_attributes[name].dup - end + unless defined?(@attributes_builder) && @attributes_builder + defaults = _default_attributes.except(*(column_names - [primary_key])) + @attributes_builder = ActiveModel::AttributeSet::Builder.new(attribute_types, defaults) end + @attributes_builder end def columns_hash # :nodoc: @@ -346,7 +346,7 @@ module ActiveRecord end def yaml_encoder # :nodoc: - @yaml_encoder ||= AttributeSet::YAMLEncoder.new(attribute_types) + @yaml_encoder ||= ActiveModel::AttributeSet::YAMLEncoder.new(attribute_types) end # Returns the type of the attribute with the given name, after applying @@ -376,7 +376,7 @@ module ActiveRecord end def _default_attributes # :nodoc: - @default_attributes ||= AttributeSet.new({}) + @default_attributes ||= ActiveModel::AttributeSet.new({}) end # Returns an array of column names as strings. diff --git a/activerecord/lib/active_record/nested_attributes.rb b/activerecord/lib/active_record/nested_attributes.rb index 435c81c153..fa20bce3a9 100644 --- a/activerecord/lib/active_record/nested_attributes.rb +++ b/activerecord/lib/active_record/nested_attributes.rb @@ -63,6 +63,18 @@ module ActiveRecord # member.update params[:member] # member.avatar.icon # => 'sad' # + # If you want to update the current avatar without providing the id, you must add <tt>:update_only</tt> option. + # + # class Member < ActiveRecord::Base + # has_one :avatar + # accepts_nested_attributes_for :avatar, update_only: true + # end + # + # params = { member: { avatar_attributes: { icon: 'sad' } } } + # member.update params[:member] + # member.avatar.id # => 2 + # member.avatar.icon # => 'sad' + # # By default you will only be able to set and update attributes on the # associated model. If you want to destroy the associated model through the # attributes hash, you have to enable it first using the diff --git a/activerecord/lib/active_record/persistence.rb b/activerecord/lib/active_record/persistence.rb index a57c60ffac..a13b0d0181 100644 --- a/activerecord/lib/active_record/persistence.rb +++ b/activerecord/lib/active_record/persistence.rb @@ -99,7 +99,9 @@ module ActiveRecord # for updating all records in a single query. def update(id = :all, attributes) if id.is_a?(Array) - id.map.with_index { |one_id, idx| update(one_id, attributes[idx]) }.compact + id.map { |one_id| find(one_id) }.each_with_index { |object, idx| + object.update(attributes[idx]) + } elsif id == :all all.each { |record| record.update(attributes) } else @@ -112,7 +114,6 @@ module ActiveRecord object.update(attributes) object end - rescue RecordNotFound end # Destroy an object (or multiple objects) that has the given id. The object is instantiated first, @@ -136,11 +137,10 @@ module ActiveRecord # Todo.destroy(todos) def destroy(id) if id.is_a?(Array) - id.map { |one_id| destroy(one_id) }.compact + find(id).each(&:destroy) else find(id).destroy end - rescue RecordNotFound end # Deletes the row with a primary key matching the +id+ argument, using a @@ -165,6 +165,38 @@ module ActiveRecord where(primary_key => id_or_array).delete_all end + def _insert_record(values) # :nodoc: + primary_key_value = nil + + if primary_key && Hash === values + arel_primary_key = arel_attribute(primary_key) + primary_key_value = values[arel_primary_key] + + if !primary_key_value && prefetch_primary_key? + primary_key_value = next_sequence_value + values[arel_primary_key] = primary_key_value + end + end + + if values.empty? + im = arel_table.compile_insert(connection.empty_insert_statement_value) + im.into arel_table + else + im = arel_table.compile_insert(_substitute_values(values)) + end + + connection.insert(im, "#{self} Create", primary_key || false, primary_key_value) + end + + def _update_record(values, id, id_was) # :nodoc: + bind = predicate_builder.build_bind_attribute(primary_key, id_was || id) + um = arel_table.where( + arel_attribute(primary_key).eq(bind) + ).compile_update(_substitute_values(values), primary_key) + + connection.update(um, "#{self} Update") + end + private # Called by +instantiate+ to decide which class to use for a new # record instance. @@ -174,6 +206,13 @@ module ActiveRecord def discriminate_class_for_record(record) self end + + def _substitute_values(values) + values.map do |attr, value| + bind = predicate_builder.build_bind_attribute(attr.name, value) + [attr, bind] + end + end end # Returns true if this object hasn't been saved yet -- that is, a record @@ -671,7 +710,7 @@ module ActiveRecord rows_affected = 0 @_trigger_update_callback = true else - rows_affected = self.class.unscoped._update_record attributes_values, id, id_in_database + rows_affected = self.class._update_record(attributes_values, id, id_in_database) @_trigger_update_callback = rows_affected > 0 end @@ -685,7 +724,7 @@ module ActiveRecord def _create_record(attribute_names = self.attribute_names) attributes_values = arel_attributes_with_values_for_create(attribute_names) - new_id = self.class.unscoped.insert attributes_values + new_id = self.class._insert_record(attributes_values) self.id ||= new_id if self.class.primary_key @new_record = false diff --git a/activerecord/lib/active_record/query_cache.rb b/activerecord/lib/active_record/query_cache.rb index 3d5babb8b7..8e23128333 100644 --- a/activerecord/lib/active_record/query_cache.rb +++ b/activerecord/lib/active_record/query_cache.rb @@ -26,16 +26,19 @@ module ActiveRecord end def self.run - caching_pool = ActiveRecord::Base.connection_pool - caching_was_enabled = caching_pool.query_cache_enabled + ActiveRecord::Base.connection_handler.connection_pool_list.map do |pool| + caching_was_enabled = pool.query_cache_enabled - caching_pool.enable_query_cache! + pool.enable_query_cache! - [caching_pool, caching_was_enabled] + [pool, caching_was_enabled] + end end - def self.complete((caching_pool, caching_was_enabled)) - caching_pool.disable_query_cache! unless caching_was_enabled + def self.complete(caching_pools) + caching_pools.each do |pool, caching_was_enabled| + pool.disable_query_cache! unless caching_was_enabled + end ActiveRecord::Base.connection_handler.connection_pool_list.each do |pool| pool.release_connection if pool.active_connection? && !pool.connection.transaction_open? diff --git a/activerecord/lib/active_record/railtie.rb b/activerecord/lib/active_record/railtie.rb index 812e1d7a00..4538ed6a5f 100644 --- a/activerecord/lib/active_record/railtie.rb +++ b/activerecord/lib/active_record/railtie.rb @@ -90,12 +90,15 @@ module ActiveRecord filename = File.join(app.config.paths["db"].first, "schema_cache.yml") if File.file?(filename) + current_version = ActiveRecord::Migrator.current_version + next if current_version.nil? + cache = YAML.load(File.read(filename)) - if cache.version == ActiveRecord::Migrator.current_version + if cache.version == current_version connection.schema_cache = cache connection_pool.schema_cache = cache.dup else - warn "Ignoring db/schema_cache.yml because it has expired. The current schema version is #{ActiveRecord::Migrator.current_version}, but the one in the cache is #{cache.version}." + warn "Ignoring db/schema_cache.yml because it has expired. The current schema version is #{current_version}, but the one in the cache is #{cache.version}." end end end @@ -177,7 +180,16 @@ end_warning initializer "active_record.clear_active_connections" do config.after_initialize do ActiveSupport.on_load(:active_record) do + # Ideally the application doesn't connect to the database during boot, + # but sometimes it does. In case it did, we want to empty out the + # connection pools so that a non-database-using process (e.g. a master + # process in a forking server model) doesn't retain a needless + # connection. If it was needed, the incremental cost of reestablishing + # this connection is trivial: the rest of the pool would need to be + # populated anyway. + clear_active_connections! + flush_idle_connections! end end end diff --git a/activerecord/lib/active_record/railties/databases.rake b/activerecord/lib/active_record/railties/databases.rake index 723272b4b2..fce3e1c5cf 100644 --- a/activerecord/lib/active_record/railties/databases.rake +++ b/activerecord/lib/active_record/railties/databases.rake @@ -4,16 +4,16 @@ require "active_record" db_namespace = namespace :db do desc "Set the environment value for the database" - task "environment:set" => [:environment, :load_config] do + task "environment:set" => :load_config do ActiveRecord::InternalMetadata.create_table ActiveRecord::InternalMetadata[:environment] = ActiveRecord::Migrator.current_environment end - task check_protected_environments: [:environment, :load_config] do + task check_protected_environments: :load_config do ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! end - task :load_config do + task load_config: :environment do ActiveRecord::Base.configurations = ActiveRecord::Tasks::DatabaseTasks.database_configuration || {} ActiveRecord::Migrator.migrations_paths = ActiveRecord::Tasks::DatabaseTasks.migrations_paths end @@ -56,7 +56,7 @@ db_namespace = namespace :db do end desc "Migrate the database (options: VERSION=x, VERBOSE=false, SCOPE=blog)." - task migrate: [:environment, :load_config] do + task migrate: :load_config do ActiveRecord::Tasks::DatabaseTasks.migrate db_namespace["_dump"].invoke end @@ -78,7 +78,7 @@ db_namespace = namespace :db do namespace :migrate do # desc 'Rollbacks the database one migration and re migrate up (options: STEP=x, VERSION=x).' - task redo: [:environment, :load_config] do + task redo: :load_config do raise "Empty VERSION provided" if ENV["VERSION"] && ENV["VERSION"].empty? if ENV["VERSION"] @@ -94,24 +94,35 @@ db_namespace = namespace :db do task reset: ["db:drop", "db:create", "db:migrate"] # desc 'Runs the "up" for a given migration VERSION.' - task up: [:environment, :load_config] do + task up: :load_config do raise "VERSION is required" if !ENV["VERSION"] || ENV["VERSION"].empty? - version = ENV["VERSION"] ? ENV["VERSION"].to_i : nil - ActiveRecord::Migrator.run(:up, ActiveRecord::Tasks::DatabaseTasks.migrations_paths, version) + ActiveRecord::Tasks::DatabaseTasks.check_target_version + + ActiveRecord::Migrator.run( + :up, + ActiveRecord::Tasks::DatabaseTasks.migrations_paths, + ActiveRecord::Tasks::DatabaseTasks.target_version + ) db_namespace["_dump"].invoke end # desc 'Runs the "down" for a given migration VERSION.' - task down: [:environment, :load_config] do + task down: :load_config do raise "VERSION is required - To go down one migration, use db:rollback" if !ENV["VERSION"] || ENV["VERSION"].empty? - version = ENV["VERSION"] ? ENV["VERSION"].to_i : nil - ActiveRecord::Migrator.run(:down, ActiveRecord::Tasks::DatabaseTasks.migrations_paths, version) + + ActiveRecord::Tasks::DatabaseTasks.check_target_version + + ActiveRecord::Migrator.run( + :down, + ActiveRecord::Tasks::DatabaseTasks.migrations_paths, + ActiveRecord::Tasks::DatabaseTasks.target_version + ) db_namespace["_dump"].invoke end desc "Display status of migrations" - task status: [:environment, :load_config] do + task status: :load_config do unless ActiveRecord::SchemaMigration.table_exists? abort "Schema migrations table does not exist yet." end @@ -129,14 +140,14 @@ db_namespace = namespace :db do end desc "Rolls the schema back to the previous version (specify steps w/ STEP=n)." - task rollback: [:environment, :load_config] do + task rollback: :load_config do step = ENV["STEP"] ? ENV["STEP"].to_i : 1 ActiveRecord::Migrator.rollback(ActiveRecord::Tasks::DatabaseTasks.migrations_paths, step) db_namespace["_dump"].invoke end # desc 'Pushes the schema to the next version (specify steps w/ STEP=n).' - task forward: [:environment, :load_config] do + task forward: :load_config do step = ENV["STEP"] ? ENV["STEP"].to_i : 1 ActiveRecord::Migrator.forward(ActiveRecord::Tasks::DatabaseTasks.migrations_paths, step) db_namespace["_dump"].invoke @@ -146,12 +157,12 @@ db_namespace = namespace :db do task reset: [ "db:drop", "db:setup" ] # desc "Retrieves the charset for the current environment's database" - task charset: [:environment, :load_config] do + task charset: :load_config do puts ActiveRecord::Tasks::DatabaseTasks.charset_current end # desc "Retrieves the collation for the current environment's database" - task collation: [:environment, :load_config] do + task collation: :load_config do begin puts ActiveRecord::Tasks::DatabaseTasks.collation_current rescue NoMethodError @@ -160,12 +171,12 @@ db_namespace = namespace :db do end desc "Retrieves the current schema version number" - task version: [:environment, :load_config] do + task version: :load_config do puts "Current version: #{ActiveRecord::Migrator.current_version}" end # desc "Raises an error if there are pending migrations" - task abort_if_pending_migrations: [:environment, :load_config] do + task abort_if_pending_migrations: :load_config do pending_migrations = ActiveRecord::Migrator.open(ActiveRecord::Tasks::DatabaseTasks.migrations_paths).pending_migrations if pending_migrations.any? @@ -188,7 +199,7 @@ db_namespace = namespace :db do namespace :fixtures do desc "Loads fixtures into the current environment's database. Load specific fixtures using FIXTURES=x,y. Load from subdirectory in test/fixtures using FIXTURES_DIR=z. Specify an alternative path (eg. spec/fixtures) using FIXTURES_PATH=spec/fixtures." - task load: [:environment, :load_config] do + task load: :load_config do require "active_record/fixtures" base_dir = ActiveRecord::Tasks::DatabaseTasks.fixtures_path @@ -210,7 +221,7 @@ db_namespace = namespace :db do end # desc "Search for a fixture given a LABEL or ID. Specify an alternative path (eg. spec/fixtures) using FIXTURES_PATH=spec/fixtures." - task identify: [:environment, :load_config] do + task identify: :load_config do require "active_record/fixtures" label, id = ENV["LABEL"], ENV["ID"] @@ -236,7 +247,7 @@ db_namespace = namespace :db do namespace :schema do desc "Creates a db/schema.rb file that is portable against any DB supported by Active Record" - task dump: [:environment, :load_config] do + task dump: :load_config do require "active_record/schema_dumper" filename = ENV["SCHEMA"] || File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, "schema.rb") File.open(filename, "w:utf-8") do |file| @@ -246,7 +257,7 @@ db_namespace = namespace :db do end desc "Loads a schema.rb file into the database" - task load: [:environment, :load_config, :check_protected_environments] do + task load: [:load_config, :check_protected_environments] do ActiveRecord::Tasks::DatabaseTasks.load_schema_current(:ruby, ENV["SCHEMA"]) end @@ -256,14 +267,14 @@ db_namespace = namespace :db do namespace :cache do desc "Creates a db/schema_cache.yml file." - task dump: [:environment, :load_config] do + task dump: :load_config do conn = ActiveRecord::Base.connection filename = File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, "schema_cache.yml") ActiveRecord::Tasks::DatabaseTasks.dump_schema_cache(conn, filename) end desc "Clears a db/schema_cache.yml file." - task clear: [:environment, :load_config] do + task clear: :load_config do filename = File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, "schema_cache.yml") rm_f filename, verbose: false end @@ -273,7 +284,7 @@ db_namespace = namespace :db do namespace :structure do desc "Dumps the database structure to db/structure.sql. Specify another file with SCHEMA=db/my_structure.sql" - task dump: [:environment, :load_config] do + task dump: :load_config do filename = ENV["SCHEMA"] || File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, "structure.sql") current_config = ActiveRecord::Tasks::DatabaseTasks.current_config ActiveRecord::Tasks::DatabaseTasks.structure_dump(current_config, filename) @@ -288,7 +299,7 @@ db_namespace = namespace :db do end desc "Recreates the databases from the structure.sql file" - task load: [:environment, :load_config, :check_protected_environments] do + task load: [:load_config, :check_protected_environments] do ActiveRecord::Tasks::DatabaseTasks.load_schema_current(:sql, ENV["SCHEMA"]) end @@ -327,12 +338,12 @@ db_namespace = namespace :db do end # desc "Empty the test database" - task purge: %w(environment load_config check_protected_environments) do + task purge: %w(load_config check_protected_environments) do ActiveRecord::Tasks::DatabaseTasks.purge ActiveRecord::Base.configurations["test"] end # desc 'Load the test schema' - task prepare: %w(environment load_config) do + task prepare: :load_config do unless ActiveRecord::Base.configurations.blank? db_namespace["test:load"].invoke end diff --git a/activerecord/lib/active_record/railties/jdbcmysql_error.rb b/activerecord/lib/active_record/railties/jdbcmysql_error.rb deleted file mode 100644 index 72c75ddd52..0000000000 --- a/activerecord/lib/active_record/railties/jdbcmysql_error.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -#FIXME Remove if ArJdbcMysql will give. -module ArJdbcMySQL #:nodoc: - class Error < StandardError #:nodoc: - attr_accessor :error_number, :sql_state - - def initialize(msg) - super - @error_number = nil - @sql_state = nil - end - - # Mysql gem compatibility - alias_method :errno, :error_number - alias_method :error, :message - end -end diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index 997cfe4b5e..081ef5771f 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -36,67 +36,6 @@ module ActiveRecord reset end - def insert(values) # :nodoc: - primary_key_value = nil - - if primary_key && Hash === values - primary_key_value = values[values.keys.find { |k| - k.name == primary_key - }] - - if !primary_key_value && klass.prefetch_primary_key? - primary_key_value = klass.next_sequence_value - values[arel_attribute(klass.primary_key)] = primary_key_value - end - end - - im = arel.create_insert - im.into @table - - substitutes = substitute_values values - - if values.empty? # empty insert - im.values = Arel.sql(connection.empty_insert_statement_value) - else - im.insert substitutes - end - - @klass.connection.insert( - im, - "#{@klass} Create", - primary_key || false, - primary_key_value, - nil, - ) - end - - def _update_record(values, id, id_was) # :nodoc: - substitutes = substitute_values values - - scope = @klass.unscoped - - if @klass.finder_needs_type_condition? - scope.unscope!(where: @klass.inheritance_column) - end - - relation = scope.where(@klass.primary_key => (id_was || id)) - um = relation - .arel - .compile_update(substitutes, @klass.primary_key) - - @klass.connection.update( - um, - "#{@klass} Update", - ) - end - - def substitute_values(values) # :nodoc: - values.map do |arel_attr, value| - bind = predicate_builder.build_bind_attribute(arel_attr.name, value) - [arel_attr, bind] - end - end - def arel_attribute(name) # :nodoc: klass.arel_attribute(name, table) end @@ -243,9 +182,10 @@ module ActiveRecord end # Converts relation objects to Array. - def to_a + def to_ary records.dup end + alias to_a to_ary def records # :nodoc: load @@ -359,6 +299,11 @@ module ActiveRecord def update_all(updates) raise ArgumentError, "Empty list of attributes to change" if updates.blank? + if eager_loading? + relation = apply_join_dependency + return relation.update_all(updates) + end + stmt = Arel::UpdateManager.new stmt.set Arel.sql(@klass.send(:sanitize_sql_for_assignment, updates)) @@ -423,6 +368,11 @@ module ActiveRecord raise ActiveRecordError.new("delete_all doesn't support #{invalid_methods.join(', ')}") end + if eager_loading? + relation = apply_join_dependency + return relation.delete_all + end + stmt = Arel::DeleteManager.new stmt.from(table) diff --git a/activerecord/lib/active_record/relation/calculations.rb b/activerecord/lib/active_record/relation/calculations.rb index 116bddce85..d49472fc70 100644 --- a/activerecord/lib/active_record/relation/calculations.rb +++ b/activerecord/lib/active_record/relation/calculations.rb @@ -130,7 +130,7 @@ module ActiveRecord # end def calculate(operation, column_name) if has_include?(column_name) - relation = apply_join_dependency(construct_join_dependency) + relation = apply_join_dependency relation.distinct! if operation.to_s.downcase == "count" relation.calculate(operation, column_name) @@ -180,9 +180,10 @@ module ActiveRecord end if has_include?(column_names.first) - relation = apply_join_dependency(construct_join_dependency) + relation = apply_join_dependency relation.pluck(*column_names) else + enforce_raw_sql_whitelist(column_names) relation = spawn relation.select_values = column_names.map { |cn| @klass.has_attribute?(cn) || @klass.attribute_alias?(cn) ? arel_attribute(cn) : cn diff --git a/activerecord/lib/active_record/relation/delegation.rb b/activerecord/lib/active_record/relation/delegation.rb index 48af777b69..4863befec8 100644 --- a/activerecord/lib/active_record/relation/delegation.rb +++ b/activerecord/lib/active_record/relation/delegation.rb @@ -38,7 +38,7 @@ module ActiveRecord # may vary depending on the klass of a relation, so we create a subclass of Relation # for each different klass, and the delegations are compiled into that subclass only. - delegate :to_xml, :encode_with, :length, :each, :uniq, :to_ary, :join, + delegate :to_xml, :encode_with, :length, :each, :uniq, :join, :[], :&, :|, :+, :-, :sample, :reverse, :rotate, :compact, :in_groups, :in_groups_of, :to_sentence, :to_formatted_s, :as_json, :shuffle, :split, :slice, :index, :rindex, to: :records diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index 707245bab2..ff06ecbee1 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -18,9 +18,10 @@ module ActiveRecord # Person.find([1]) # returns an array for the object with ID = 1 # Person.where("administrator = 1").order("created_on DESC").find(1) # - # NOTE: The returned records may not be in the same order as the ids you - # provide since database rows are unordered. You will need to provide an explicit QueryMethods#order - # option if you want the results to be sorted. + # NOTE: The returned records are in the same order as the ids you provide. + # If you want the results to be sorted by database, you can use ActiveRecord::QueryMethods#where + # method and provide an explicit ActiveRecord::QueryMethods#order option. + # But ActiveRecord::QueryMethods#where method doesn't raise ActiveRecord::RecordNotFound. # # ==== Find with lock # @@ -88,7 +89,7 @@ module ActiveRecord where(arg, *args).take! rescue ::RangeError raise RecordNotFound.new("Couldn't find #{@klass.name} with an out of range value", - @klass.name) + @klass.name, @klass.primary_key) end # Gives a record (or N records if a parameter is supplied) without any implied @@ -284,7 +285,7 @@ module ActiveRecord # * Hash - Finds the record that matches these +find+-style conditions # (such as <tt>{name: 'David'}</tt>). # * +false+ - Returns always +false+. - # * No args - Returns +false+ if the table is empty, +true+ otherwise. + # * No args - Returns +false+ if the relation is empty, +true+ otherwise. # # For more information about specifying conditions as a hash or array, # see the Conditions section in the introduction to ActiveRecord::Base. @@ -300,6 +301,7 @@ module ActiveRecord # Person.exists?(name: 'David') # Person.exists?(false) # Person.exists? + # Person.where(name: 'Spartacus', rating: 4).exists? def exists?(conditions = :none) if Base === conditions raise ArgumentError, <<-MSG.squish @@ -338,7 +340,7 @@ module ActiveRecord if ids.nil? error = "Couldn't find #{name}".dup error << " with#{conditions}" if conditions - raise RecordNotFound.new(error, name) + raise RecordNotFound.new(error, name, key) elsif Array(ids).size == 1 error = "Couldn't find #{name} with '#{key}'=#{ids}#{conditions}" raise RecordNotFound.new(error, name, key, ids) @@ -346,7 +348,7 @@ module ActiveRecord error = "Couldn't find all #{name.pluralize} with '#{key}': ".dup error << "(#{ids.join(", ")})#{conditions} (found #{result_size} results, but was looking for #{expected_size})." error << " Couldn't find #{name.pluralize(not_found_ids.size)} with #{key.to_s.pluralize(not_found_ids.size)} #{not_found_ids.join(', ')}." if not_found_ids - raise RecordNotFound.new(error, name, primary_key, ids) + raise RecordNotFound.new(error, name, key, ids) end end @@ -394,7 +396,7 @@ module ActiveRecord ) end - def apply_join_dependency(join_dependency) + def apply_join_dependency(join_dependency = construct_join_dependency) relation = except(:includes, :eager_load, :preload).joins!(join_dependency) if using_limitable_reflections?(join_dependency.reflections) @@ -432,9 +434,12 @@ module ActiveRecord ids = ids.flatten.compact.uniq + model_name = @klass.name + case ids.size when 0 - raise RecordNotFound, "Couldn't find #{@klass.name} without an ID" + error_message = "Couldn't find #{model_name} without an ID" + raise RecordNotFound.new(error_message, model_name, primary_key) when 1 result = find_one(ids.first) expects_array ? [ result ] : result @@ -442,7 +447,8 @@ module ActiveRecord find_some(ids) end rescue ::RangeError - raise RecordNotFound, "Couldn't find #{@klass.name} with an out of range ID" + error_message = "Couldn't find #{model_name} with an out of range ID" + raise RecordNotFound.new(error_message, model_name, primary_key, ids) end def find_one(id) diff --git a/activerecord/lib/active_record/relation/merger.rb b/activerecord/lib/active_record/relation/merger.rb index ebc72d28fd..b736b21525 100644 --- a/activerecord/lib/active_record/relation/merger.rb +++ b/activerecord/lib/active_record/relation/merger.rb @@ -112,22 +112,20 @@ module ActiveRecord if other.klass == relation.klass relation.joins!(*other.joins_values) else - joins_dependency, rest = other.joins_values.partition do |join| + alias_tracker = nil + joins_dependency = other.joins_values.map do |join| case join when Hash, Symbol, Array - true + alias_tracker ||= other.alias_tracker + ActiveRecord::Associations::JoinDependency.new( + other.klass, other.table, join, alias_tracker + ) else - false + join end end - join_dependency = ActiveRecord::Associations::JoinDependency.new( - other.klass, other.table, joins_dependency, other.alias_tracker - ) - - relation.joins! rest - - @relation = relation.joins join_dependency + relation.joins!(*joins_dependency) end end diff --git a/activerecord/lib/active_record/relation/predicate_builder/relation_handler.rb b/activerecord/lib/active_record/relation/predicate_builder/relation_handler.rb index f51ea4fde0..c8bbfa5051 100644 --- a/activerecord/lib/active_record/relation/predicate_builder/relation_handler.rb +++ b/activerecord/lib/active_record/relation/predicate_builder/relation_handler.rb @@ -4,6 +4,10 @@ module ActiveRecord class PredicateBuilder class RelationHandler # :nodoc: def call(attribute, value) + if value.eager_loading? + value = value.send(:apply_join_dependency) + end + if value.select_values.empty? value = value.select(value.arel_attribute(value.klass.primary_key)) end diff --git a/activerecord/lib/active_record/relation/query_attribute.rb b/activerecord/lib/active_record/relation/query_attribute.rb index fad08e2613..3532f28858 100644 --- a/activerecord/lib/active_record/relation/query_attribute.rb +++ b/activerecord/lib/active_record/relation/query_attribute.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -require "active_record/attribute" +require "active_model/attribute" module ActiveRecord class Relation - class QueryAttribute < Attribute # :nodoc: + class QueryAttribute < ActiveModel::Attribute # :nodoc: def type_cast(value) value end diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 897ff5c8af..749223422f 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -295,6 +295,7 @@ module ActiveRecord spawn.order!(*args) end + # Same as #order but operates on relation in-place instead of copying. def order!(*args) # :nodoc: preprocess_order_args(args) @@ -316,6 +317,7 @@ module ActiveRecord spawn.reorder!(*args) end + # Same as #reorder but operates on relation in-place instead of copying. def reorder!(*args) # :nodoc: preprocess_order_args(args) @@ -930,7 +932,7 @@ module ActiveRecord arel.where(where_clause.ast) unless where_clause.empty? arel.having(having_clause.ast) unless having_clause.empty? if limit_value - limit_attribute = Attribute.with_cast_value( + limit_attribute = ActiveModel::Attribute.with_cast_value( "LIMIT".freeze, connection.sanitize_limit(limit_value), Type.default_value, @@ -938,7 +940,7 @@ module ActiveRecord arel.take(Arel::Nodes::BindParam.new(limit_attribute)) end if offset_value - offset_attribute = Attribute.with_cast_value( + offset_attribute = ActiveModel::Attribute.with_cast_value( "OFFSET".freeze, offset_value.to_i, Type.default_value, @@ -963,6 +965,9 @@ module ActiveRecord name = from_clause.name case opts when Relation + if opts.eager_loading? + opts = opts.send(:apply_join_dependency) + end name ||= "subquery" opts.arel.as(name.to_s) else @@ -1035,6 +1040,8 @@ module ActiveRecord def build_select(arel) if select_values.any? arel.project(*arel_columns(select_values.uniq)) + elsif @klass.ignored_columns.any? + arel.project(*arel_columns(@klass.column_names)) else arel.project(table[Arel.star]) end @@ -1071,7 +1078,7 @@ module ActiveRecord end o.split(",").map! do |s| s.strip! - s.gsub!(/\sasc\Z/i, " DESC") || s.gsub!(/\sdesc\Z/i, " ASC") || s.concat(" DESC") + s.gsub!(/\sasc\Z/i, " DESC") || s.gsub!(/\sdesc\Z/i, " ASC") || (s << " DESC") end else o @@ -1080,6 +1087,10 @@ module ActiveRecord end def does_not_support_reverse?(order) + # Account for String subclasses like Arel::Nodes::SqlLiteral that + # override methods like #count. + order = String.new(order) unless order.instance_of?(String) + # Uses SQL function with multiple arguments. (order.include?(",") && order.split(",").find { |section| section.count("(") != section.count(")") }) || # Uses "nulls first" like construction. @@ -1113,6 +1124,12 @@ module ActiveRecord klass.send(:sanitize_sql_for_order, arg) end order_args.flatten! + + @klass.enforce_raw_sql_whitelist( + order_args.flat_map { |a| a.is_a?(Hash) ? a.keys : a }, + whitelist: AttributeMethods::ClassMethods::COLUMN_NAME_ORDER_WHITELIST + ) + validate_order_args(order_args) references = order_args.grep(String) diff --git a/activerecord/lib/active_record/relation/where_clause.rb b/activerecord/lib/active_record/relation/where_clause.rb index 752bb38481..a502713e56 100644 --- a/activerecord/lib/active_record/relation/where_clause.rb +++ b/activerecord/lib/active_record/relation/where_clause.rb @@ -47,7 +47,7 @@ module ActiveRecord end def to_h(table_name = nil) - equalities = predicates.grep(Arel::Nodes::Equality) + equalities = equalities(predicates) if table_name equalities = equalities.select do |node| node.left.relation.name == table_name @@ -90,6 +90,20 @@ module ActiveRecord end private + def equalities(predicates) + equalities = [] + + predicates.each do |node| + case node + when Arel::Nodes::Equality + equalities << node + when Arel::Nodes::And + equalities.concat equalities(node.children) + end + end + + equalities + end def predicates_unreferenced_by(other) predicates.reject do |n| @@ -121,7 +135,7 @@ module ActiveRecord end def except_predicates(columns) - self.predicates.reject do |node| + predicates.reject do |node| case node when Arel::Nodes::Between, Arel::Nodes::In, Arel::Nodes::NotIn, Arel::Nodes::Equality, Arel::Nodes::NotEqual, Arel::Nodes::LessThan, Arel::Nodes::LessThanOrEqual, Arel::Nodes::GreaterThan, Arel::Nodes::GreaterThanOrEqual subrelation = (node.left.kind_of?(Arel::Attributes::Attribute) ? node.left : node.right) diff --git a/activerecord/lib/active_record/sanitization.rb b/activerecord/lib/active_record/sanitization.rb index 1c3099f55c..21f8bc7cb2 100644 --- a/activerecord/lib/active_record/sanitization.rb +++ b/activerecord/lib/active_record/sanitization.rb @@ -63,7 +63,17 @@ module ActiveRecord # # => "id ASC" def sanitize_sql_for_order(condition) # :doc: if condition.is_a?(Array) && condition.first.to_s.include?("?") - sanitize_sql_array(condition) + enforce_raw_sql_whitelist([condition.first], + whitelist: AttributeMethods::ClassMethods::COLUMN_NAME_ORDER_WHITELIST + ) + + # Ensure we aren't dealing with a subclass of String that might + # override methods we use (eg. Arel::Nodes::SqlLiteral). + if condition.first.kind_of?(String) && !condition.first.instance_of?(String) + condition = [String.new(condition.first), *condition[1..-1]] + end + + Arel.sql(sanitize_sql_array(condition)) else condition end @@ -110,7 +120,8 @@ module ActiveRecord def sanitize_sql_hash_for_assignment(attrs, table) # :doc: c = connection attrs.map do |attr, value| - value = type_for_attribute(attr.to_s).serialize(value) + type = type_for_attribute(attr.to_s) + value = type.serialize(type.cast(value)) "#{c.quote_table_name_for_assignment(table, attr)} = #{c.quote(value)}" end.join(", ") end diff --git a/activerecord/lib/active_record/schema_dumper.rb b/activerecord/lib/active_record/schema_dumper.rb index 66f7d29886..16ccba6b6c 100644 --- a/activerecord/lib/active_record/schema_dumper.rb +++ b/activerecord/lib/active_record/schema_dumper.rb @@ -184,8 +184,9 @@ HEADER "name: #{index.name.inspect}", ] index_parts << "unique: true" if index.unique - index_parts << "length: { #{format_options(index.lengths)} }" if index.lengths.present? - index_parts << "order: { #{format_options(index.orders)} }" if index.orders.present? + index_parts << "length: #{format_index_parts(index.lengths)}" if index.lengths.present? + index_parts << "order: #{format_index_parts(index.orders)}" if index.orders.present? + index_parts << "opclass: #{format_index_parts(index.opclasses)}" if index.opclasses.present? index_parts << "where: #{index.where.inspect}" if index.where index_parts << "using: #{index.using.inspect}" if !@connection.default_index_type?(index) index_parts << "type: #{index.type.inspect}" if index.type @@ -231,6 +232,14 @@ HEADER options.map { |key, value| "#{key}: #{value.inspect}" }.join(", ") end + def format_index_parts(options) + if options.is_a?(Hash) + "{ #{format_options(options)} }" + else + options.inspect + end + end + def remove_prefix_and_suffix(table) prefix = Regexp.escape(@options[:table_name_prefix].to_s) suffix = Regexp.escape(@options[:table_name_suffix].to_s) diff --git a/activerecord/lib/active_record/scoping.rb b/activerecord/lib/active_record/scoping.rb index da585a9562..01ac56570a 100644 --- a/activerecord/lib/active_record/scoping.rb +++ b/activerecord/lib/active_record/scoping.rb @@ -11,23 +11,23 @@ module ActiveRecord include Named end - module ClassMethods - def current_scope(skip_inherited_scope = false) # :nodoc: + module ClassMethods # :nodoc: + def current_scope(skip_inherited_scope = false) ScopeRegistry.value_for(:current_scope, self, skip_inherited_scope) end - def current_scope=(scope) #:nodoc: + def current_scope=(scope) ScopeRegistry.set_value_for(:current_scope, self, scope) end # Collects attributes from scopes that should be applied when creating # an AR instance for the particular class this is called on. - def scope_attributes # :nodoc: + def scope_attributes all.scope_for_create end # Are there attributes associated with this scope? - def scope_attributes? # :nodoc: + def scope_attributes? current_scope end end diff --git a/activerecord/lib/active_record/scoping/default.rb b/activerecord/lib/active_record/scoping/default.rb index 86ae374318..8c612df27a 100644 --- a/activerecord/lib/active_record/scoping/default.rb +++ b/activerecord/lib/active_record/scoping/default.rb @@ -111,7 +111,7 @@ 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) + (base_rel ||= relation).merge!(scope) end end elsif default_scopes.any? @@ -119,7 +119,7 @@ module ActiveRecord evaluate_default_scope do default_scopes.inject(base_rel) do |default_scope, scope| scope = scope.respond_to?(:to_proc) ? scope : scope.method(:call) - default_scope.merge(base_rel.instance_exec(&scope)) + default_scope.merge!(base_rel.instance_exec(&scope)) end end end diff --git a/activerecord/lib/active_record/scoping/named.rb b/activerecord/lib/active_record/scoping/named.rb index 6fa096c1fe..752655aa05 100644 --- a/activerecord/lib/active_record/scoping/named.rb +++ b/activerecord/lib/active_record/scoping/named.rb @@ -24,8 +24,14 @@ module ActiveRecord # You can define a scope that applies to all finders using # {default_scope}[rdoc-ref:Scoping::Default::ClassMethods#default_scope]. def all + current_scope = self.current_scope + if current_scope - current_scope.clone + if self == current_scope.klass + current_scope.clone + else + relation.merge!(current_scope) + end else default_scoped end @@ -165,6 +171,12 @@ module ActiveRecord "a class method with the same name." end + if method_defined_within?(name, Relation) + raise ArgumentError, "You tried to define a scope named \"#{name}\" " \ + "on the model \"#{self.name}\", but ActiveRecord::Relation already defined " \ + "an instance method with the same name." + end + valid_scope_name?(name) extension = Module.new(&block) if block diff --git a/activerecord/lib/active_record/tasks/database_tasks.rb b/activerecord/lib/active_record/tasks/database_tasks.rb index ff388ff1f6..4657e51e6d 100644 --- a/activerecord/lib/active_record/tasks/database_tasks.rb +++ b/activerecord/lib/active_record/tasks/database_tasks.rb @@ -164,13 +164,12 @@ module ActiveRecord end def migrate - raise "Empty VERSION provided" if ENV["VERSION"] && ENV["VERSION"].empty? + check_target_version verbose = ENV["VERBOSE"] ? ENV["VERBOSE"] != "false" : true - version = ENV["VERSION"] ? ENV["VERSION"].to_i : nil scope = ENV["SCOPE"] verbose_was, Migration.verbose = Migration.verbose, verbose - Migrator.migrate(migrations_paths, version) do |migration| + Migrator.migrate(migrations_paths, target_version) do |migration| scope.blank? || scope == migration.scope end ActiveRecord::Base.clear_cache! @@ -178,6 +177,16 @@ module ActiveRecord Migration.verbose = verbose_was end + def check_target_version + if target_version && !(Migration::MigrationFilenameRegexp.match?(ENV["VERSION"]) || /\A\d+\z/.match?(ENV["VERSION"])) + raise "Invalid format of target version: `VERSION=#{ENV['VERSION']}`" + end + end + + def target_version + ENV["VERSION"].to_i if ENV["VERSION"] && !ENV["VERSION"].empty? + end + def charset_current(environment = env) charset ActiveRecord::Base.configurations[environment] end diff --git a/activerecord/lib/active_record/tasks/mysql_database_tasks.rb b/activerecord/lib/active_record/tasks/mysql_database_tasks.rb index 2f91884f59..e697fa6def 100644 --- a/activerecord/lib/active_record/tasks/mysql_database_tasks.rb +++ b/activerecord/lib/active_record/tasks/mysql_database_tasks.rb @@ -3,8 +3,6 @@ module ActiveRecord module Tasks # :nodoc: class MySQLDatabaseTasks # :nodoc: - ACCESS_DENIED_ERROR = 1045 - delegate :connection, :establish_connection, to: ActiveRecord::Base def initialize(configuration) @@ -21,20 +19,6 @@ module ActiveRecord else raise end - rescue error_class => error - if error.respond_to?(:errno) && error.errno == ACCESS_DENIED_ERROR - $stdout.print error.message - establish_connection root_configuration_without_database - connection.create_database configuration["database"], creation_options - if configuration["username"] != "root" - connection.execute grant_statement.gsub(/\s+/, " ").strip - end - establish_connection configuration - else - $stderr.puts error.inspect - $stderr.puts "Couldn't create database for #{configuration.inspect}, #{creation_options.inspect}" - $stderr.puts "(If you set the charset manually, make sure you have a matching collation)" if configuration["encoding"] - end end def drop @@ -99,37 +83,6 @@ module ActiveRecord end end - def error_class - if configuration["adapter"].include?("jdbc") - require "active_record/railties/jdbcmysql_error" - ArJdbcMySQL::Error - elsif defined?(Mysql2) - Mysql2::Error - else - StandardError - end - end - - def grant_statement - <<-SQL -GRANT ALL PRIVILEGES ON `#{configuration['database']}`.* - TO '#{configuration['username']}'@'localhost' -IDENTIFIED BY '#{configuration['password']}' WITH GRANT OPTION; - SQL - end - - def root_configuration_without_database - configuration_without_database.merge( - "username" => "root", - "password" => root_password - ) - end - - def root_password - $stdout.print "Please provide the root password for your MySQL installation\n>" - $stdin.gets.strip - end - def prepare_command_options args = { "host" => "--host", diff --git a/activerecord/lib/rails/generators/active_record/application_record/templates/application_record.rb b/activerecord/lib/rails/generators/active_record/application_record/templates/application_record.rb.tt index 60050e0bf8..60050e0bf8 100644 --- a/activerecord/lib/rails/generators/active_record/application_record/templates/application_record.rb +++ b/activerecord/lib/rails/generators/active_record/application_record/templates/application_record.rb.tt diff --git a/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb b/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt index 5f7201cfe1..5f7201cfe1 100644 --- a/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb +++ b/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt diff --git a/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb b/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt index 481c70201b..481c70201b 100644 --- a/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb +++ b/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt diff --git a/activerecord/lib/rails/generators/active_record/model/templates/model.rb b/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt index 55dc65c8ad..55dc65c8ad 100644 --- a/activerecord/lib/rails/generators/active_record/model/templates/model.rb +++ b/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt diff --git a/activerecord/lib/rails/generators/active_record/model/templates/module.rb b/activerecord/lib/rails/generators/active_record/model/templates/module.rb.tt index a3bf1c37b6..a3bf1c37b6 100644 --- a/activerecord/lib/rails/generators/active_record/model/templates/module.rb +++ b/activerecord/lib/rails/generators/active_record/model/templates/module.rb.tt diff --git a/activerecord/test/cases/adapters/mysql2/connection_test.rb b/activerecord/test/cases/adapters/mysql2/connection_test.rb index e61c70848a..13b4096671 100644 --- a/activerecord/test/cases/adapters/mysql2/connection_test.rb +++ b/activerecord/test/cases/adapters/mysql2/connection_test.rb @@ -174,10 +174,10 @@ class Mysql2ConnectionTest < ActiveRecord::Mysql2TestCase assert_equal "SCHEMA", @subscriber.logged[0][1] end - def test_logs_name_rename_column_sql + def test_logs_name_rename_column_for_alter @connection.execute "CREATE TABLE `bar_baz` (`foo` varchar(255))" @subscriber.logged.clear - @connection.send(:rename_column_sql, "bar_baz", "foo", "foo2") + @connection.send(:rename_column_for_alter, "bar_baz", "foo", "foo2") assert_equal "SCHEMA", @subscriber.logged[0][1] ensure @connection.execute "DROP TABLE `bar_baz`" diff --git a/activerecord/test/cases/adapters/mysql2/explain_test.rb b/activerecord/test/cases/adapters/mysql2/explain_test.rb index 2736f7cf0e..b8e778f0b0 100644 --- a/activerecord/test/cases/adapters/mysql2/explain_test.rb +++ b/activerecord/test/cases/adapters/mysql2/explain_test.rb @@ -1,23 +1,23 @@ # frozen_string_literal: true require "cases/helper" -require "models/developer" -require "models/computer" +require "models/author" +require "models/post" class Mysql2ExplainTest < ActiveRecord::Mysql2TestCase - fixtures :developers + fixtures :authors def test_explain_for_one_query - explain = Developer.where(id: 1).explain - assert_match %(EXPLAIN for: SELECT `developers`.* FROM `developers` WHERE `developers`.`id` = 1), explain - assert_match %r(developers |.* const), explain + explain = Author.where(id: 1).explain + assert_match %(EXPLAIN for: SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1), explain + assert_match %r(authors |.* const), explain end def test_explain_with_eager_loading - explain = Developer.where(id: 1).includes(:audit_logs).explain - assert_match %(EXPLAIN for: SELECT `developers`.* FROM `developers` WHERE `developers`.`id` = 1), explain - assert_match %r(developers |.* const), explain - assert_match %(EXPLAIN for: SELECT `audit_logs`.* FROM `audit_logs` WHERE `audit_logs`.`developer_id` = 1), explain - assert_match %r(audit_logs |.* ALL), explain + explain = Author.where(id: 1).includes(:posts).explain + assert_match %(EXPLAIN for: SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1), explain + assert_match %r(authors |.* const), explain + assert_match %(EXPLAIN for: SELECT `posts`.* FROM `posts` WHERE `posts`.`author_id` = 1), explain + assert_match %r(posts |.* ALL), explain end end diff --git a/activerecord/test/cases/adapters/mysql2/transaction_test.rb b/activerecord/test/cases/adapters/mysql2/transaction_test.rb index 25d9f69a89..cb183cc54c 100644 --- a/activerecord/test/cases/adapters/mysql2/transaction_test.rb +++ b/activerecord/test/cases/adapters/mysql2/transaction_test.rb @@ -60,9 +60,87 @@ module ActiveRecord end end - test "raises TransactionTimeout when mysql raises ER_LOCK_WAIT_TIMEOUT" do - assert_raises(ActiveRecord::TransactionTimeout) do - ActiveRecord::Base.connection.execute("SIGNAL SQLSTATE 'HY000' SET MESSAGE_TEXT = 'Testing error', MYSQL_ERRNO = 1205;") + test "raises LockWaitTimeout when lock wait timeout exceeded" do + assert_raises(ActiveRecord::LockWaitTimeout) do + s = Sample.create!(value: 1) + latch1 = Concurrent::CountDownLatch.new + latch2 = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch1.count_down + latch2.wait + end + end + + begin + Sample.transaction do + latch1.wait + Sample.connection.execute("SET innodb_lock_wait_timeout = 1") + Sample.lock.find(s.id) + end + ensure + Sample.connection.execute("SET innodb_lock_wait_timeout = DEFAULT") + latch2.count_down + thread.join + end + end + end + + test "raises StatementTimeout when statement timeout exceeded" do + skip unless ActiveRecord::Base.connection.show_variable("max_execution_time") + assert_raises(ActiveRecord::StatementTimeout) do + s = Sample.create!(value: 1) + latch1 = Concurrent::CountDownLatch.new + latch2 = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch1.count_down + latch2.wait + end + end + + begin + Sample.transaction do + latch1.wait + Sample.connection.execute("SET max_execution_time = 1") + Sample.lock.find(s.id) + end + ensure + Sample.connection.execute("SET max_execution_time = DEFAULT") + latch2.count_down + thread.join + end + end + end + + test "raises QueryCanceled when canceling statement due to user request" do + assert_raises(ActiveRecord::QueryCanceled) do + s = Sample.create!(value: 1) + latch = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch.count_down + sleep(0.5) + conn = Sample.connection + pid = conn.query_value("SELECT id FROM information_schema.processlist WHERE info LIKE '% FOR UPDATE'") + conn.execute("KILL QUERY #{pid}") + end + end + + begin + Sample.transaction do + latch.wait + Sample.lock.find(s.id) + end + ensure + thread.join + end end end end diff --git a/activerecord/test/cases/adapters/postgresql/active_schema_test.rb b/activerecord/test/cases/adapters/postgresql/active_schema_test.rb index 9929237546..99c53dadeb 100644 --- a/activerecord/test/cases/adapters/postgresql/active_schema_test.rb +++ b/activerecord/test/cases/adapters/postgresql/active_schema_test.rb @@ -59,6 +59,9 @@ class PostgresqlActiveSchemaTest < ActiveRecord::PostgreSQLTestCase assert_equal expected, add_index(:people, "lower(last_name)", using: type, unique: true) end + expected = %(CREATE INDEX "index_people_on_last_name" ON "people" USING gist ("last_name" bpchar_pattern_ops)) + assert_equal expected, add_index(:people, :last_name, using: :gist, opclass: { last_name: :bpchar_pattern_ops }) + assert_raise ArgumentError do add_index(:people, :last_name, algorithm: :copy) end diff --git a/activerecord/test/cases/adapters/postgresql/explain_test.rb b/activerecord/test/cases/adapters/postgresql/explain_test.rb index 16fec94ede..be525383e9 100644 --- a/activerecord/test/cases/adapters/postgresql/explain_test.rb +++ b/activerecord/test/cases/adapters/postgresql/explain_test.rb @@ -1,22 +1,22 @@ # frozen_string_literal: true require "cases/helper" -require "models/developer" -require "models/computer" +require "models/author" +require "models/post" class PostgreSQLExplainTest < ActiveRecord::PostgreSQLTestCase - fixtures :developers + fixtures :authors def test_explain_for_one_query - explain = Developer.where(id: 1).explain - assert_match %r(EXPLAIN for: SELECT "developers"\.\* FROM "developers" WHERE "developers"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain + explain = Author.where(id: 1).explain + assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain assert_match %(QUERY PLAN), explain end def test_explain_with_eager_loading - explain = Developer.where(id: 1).includes(:audit_logs).explain + explain = Author.where(id: 1).includes(:posts).explain assert_match %(QUERY PLAN), explain - assert_match %r(EXPLAIN for: SELECT "developers"\.\* FROM "developers" WHERE "developers"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain - assert_match %r(EXPLAIN for: SELECT "audit_logs"\.\* FROM "audit_logs" WHERE "audit_logs"\."developer_id" = (?:\$1 \[\["developer_id", 1\]\]|1)), explain + assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain + assert_match %r(EXPLAIN for: SELECT "posts"\.\* FROM "posts" WHERE "posts"\."author_id" = (?:\$1 \[\["author_id", 1\]\]|1)), explain end end diff --git a/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb index f199519d86..1951230c8a 100644 --- a/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb +++ b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb @@ -248,12 +248,12 @@ module ActiveRecord def test_index_with_opclass with_example_table do - @connection.add_index "ex", "data varchar_pattern_ops" - index = @connection.indexes("ex").find { |idx| idx.name == "index_ex_on_data_varchar_pattern_ops" } - assert_equal "data varchar_pattern_ops", index.columns + @connection.add_index "ex", "data", opclass: "varchar_pattern_ops" + index = @connection.indexes("ex").find { |idx| idx.name == "index_ex_on_data" } + assert_equal ["data"], index.columns - @connection.remove_index "ex", "data varchar_pattern_ops" - assert_not @connection.indexes("ex").find { |idx| idx.name == "index_ex_on_data_varchar_pattern_ops" } + @connection.remove_index "ex", "data" + assert_not @connection.indexes("ex").find { |idx| idx.name == "index_ex_on_data" } end end diff --git a/activerecord/test/cases/adapters/postgresql/schema_test.rb b/activerecord/test/cases/adapters/postgresql/schema_test.rb index 5a64da028b..1126908761 100644 --- a/activerecord/test/cases/adapters/postgresql/schema_test.rb +++ b/activerecord/test/cases/adapters/postgresql/schema_test.rb @@ -459,7 +459,7 @@ class SchemaTest < ActiveRecord::PostgreSQLTestCase assert_equal :btree, index_d.using assert_equal :gin, index_e.using - assert_equal :desc, index_d.orders[INDEX_D_COLUMN] + assert_equal :desc, index_d.orders end end @@ -500,6 +500,38 @@ class SchemaForeignKeyTest < ActiveRecord::PostgreSQLTestCase end end +class SchemaIndexOpclassTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table "trains" do |t| + t.string :name + t.text :description + end + end + + teardown do + @connection.drop_table "trains", if_exists: true + end + + def test_string_opclass_is_dumped + @connection.execute "CREATE INDEX trains_name_and_description ON trains USING btree(name text_pattern_ops, description text_pattern_ops)" + + output = dump_table_schema "trains" + + assert_match(/opclass: :text_pattern_ops/, output) + end + + def test_non_default_opclass_is_dumped + @connection.execute "CREATE INDEX trains_name_and_description ON trains USING btree(name, description text_pattern_ops)" + + output = dump_table_schema "trains" + + assert_match(/opclass: \{ description: :text_pattern_ops \}/, output) + end +end + class DefaultsUsingMultipleSchemasAndDomainTest < ActiveRecord::PostgreSQLTestCase setup do @connection = ActiveRecord::Base.connection diff --git a/activerecord/test/cases/adapters/postgresql/transaction_test.rb b/activerecord/test/cases/adapters/postgresql/transaction_test.rb index f56adf4a5e..c24dfeb345 100644 --- a/activerecord/test/cases/adapters/postgresql/transaction_test.rb +++ b/activerecord/test/cases/adapters/postgresql/transaction_test.rb @@ -91,6 +91,90 @@ module ActiveRecord end end + test "raises LockWaitTimeout when lock wait timeout exceeded" do + skip unless ActiveRecord::Base.connection.postgresql_version >= 90300 + assert_raises(ActiveRecord::LockWaitTimeout) do + s = Sample.create!(value: 1) + latch1 = Concurrent::CountDownLatch.new + latch2 = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch1.count_down + latch2.wait + end + end + + begin + Sample.transaction do + latch1.wait + Sample.connection.execute("SET lock_timeout = 1") + Sample.lock.find(s.id) + end + ensure + Sample.connection.execute("SET lock_timeout = DEFAULT") + latch2.count_down + thread.join + end + end + end + + test "raises QueryCanceled when statement timeout exceeded" do + assert_raises(ActiveRecord::QueryCanceled) do + s = Sample.create!(value: 1) + latch1 = Concurrent::CountDownLatch.new + latch2 = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch1.count_down + latch2.wait + end + end + + begin + Sample.transaction do + latch1.wait + Sample.connection.execute("SET statement_timeout = 1") + Sample.lock.find(s.id) + end + ensure + Sample.connection.execute("SET statement_timeout = DEFAULT") + latch2.count_down + thread.join + end + end + end + + test "raises QueryCanceled when canceling statement due to user request" do + assert_raises(ActiveRecord::QueryCanceled) do + s = Sample.create!(value: 1) + latch = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch.count_down + sleep(0.5) + conn = Sample.connection + pid = conn.query_value("SELECT pid FROM pg_stat_activity WHERE query LIKE '% FOR UPDATE'") + conn.execute("SELECT pg_cancel_backend(#{pid})") + end + end + + begin + Sample.transaction do + latch.wait + Sample.lock.find(s.id) + end + ensure + thread.join + end + end + end + private def with_warning_suppression diff --git a/activerecord/test/cases/adapters/sqlite3/explain_test.rb b/activerecord/test/cases/adapters/sqlite3/explain_test.rb index 3b081d34e1..b6d2ccdb53 100644 --- a/activerecord/test/cases/adapters/sqlite3/explain_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/explain_test.rb @@ -1,23 +1,23 @@ # frozen_string_literal: true require "cases/helper" -require "models/developer" -require "models/computer" +require "models/author" +require "models/post" class SQLite3ExplainTest < ActiveRecord::SQLite3TestCase - fixtures :developers + fixtures :authors def test_explain_for_one_query - explain = Developer.where(id: 1).explain - assert_match %r(EXPLAIN for: SELECT "developers"\.\* FROM "developers" WHERE "developers"\."id" = (?:\? \[\["id", 1\]\]|1)), explain - assert_match(/(SEARCH )?TABLE developers USING (INTEGER )?PRIMARY KEY/, explain) + explain = Author.where(id: 1).explain + assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\? \[\["id", 1\]\]|1)), explain + assert_match(/(SEARCH )?TABLE authors USING (INTEGER )?PRIMARY KEY/, explain) end def test_explain_with_eager_loading - explain = Developer.where(id: 1).includes(:audit_logs).explain - assert_match %r(EXPLAIN for: SELECT "developers"\.\* FROM "developers" WHERE "developers"\."id" = (?:\? \[\["id", 1\]\]|1)), explain - assert_match(/(SEARCH )?TABLE developers USING (INTEGER )?PRIMARY KEY/, explain) - assert_match %r(EXPLAIN for: SELECT "audit_logs"\.\* FROM "audit_logs" WHERE "audit_logs"\."developer_id" = (?:\? \[\["developer_id", 1\]\]|1)), explain - assert_match(/(SCAN )?TABLE audit_logs/, explain) + explain = Author.where(id: 1).includes(:posts).explain + assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\? \[\["id", 1\]\]|1)), explain + assert_match(/(SEARCH )?TABLE authors USING (INTEGER )?PRIMARY KEY/, explain) + assert_match %r(EXPLAIN for: SELECT "posts"\.\* FROM "posts" WHERE "posts"\."author_id" = (?:\? \[\["author_id", 1\]\]|1)), explain + assert_match(/(SCAN )?TABLE posts/, explain) end end diff --git a/activerecord/test/cases/adapters/sqlite3/json_test.rb b/activerecord/test/cases/adapters/sqlite3/json_test.rb index 568a524058..6f247fcd22 100644 --- a/activerecord/test/cases/adapters/sqlite3/json_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/json_test.rb @@ -9,8 +9,8 @@ class SQLite3JSONTest < ActiveRecord::SQLite3TestCase def setup super @connection.create_table("json_data_type") do |t| - t.column "payload", :json, default: {} - t.column "settings", :json + t.json "payload", default: {} + t.json "settings" end end diff --git a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb index 1f057fe5c6..14f4997d5b 100644 --- a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb @@ -360,6 +360,24 @@ module ActiveRecord end end + class Barcode < ActiveRecord::Base + end + + def test_existing_records_have_custom_primary_key + connection = Barcode.connection + connection.create_table(:barcodes, primary_key: "code", id: :string, limit: 42, force: true) do |t| + t.text :other_attr + end + code = "214fe0c2-dd47-46df-b53b-66090b3c1d40" + Barcode.create! code: code, other_attr: "xxx" + + connection.change_table "barcodes" do |t| + connection.remove_column("barcodes", "other_attr") + end + + assert_equal code, Barcode.first.id + end + def test_supports_extensions assert_not @conn.supports_extensions?, "does not support extensions" end diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb index e69cfe5e52..829e12fbc8 100644 --- a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb +++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb @@ -37,7 +37,7 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase def test_eager_association_loading_with_hmt_does_not_table_name_collide_when_joining_associations authors = Author.joins(:posts).eager_load(:comments).where(posts: { tags_count: 1 }).to_a - assert_equal 1, assert_no_queries { authors.size } + assert_equal 3, assert_no_queries { authors.size } assert_equal 10, assert_no_queries { authors[0].comments.size } end diff --git a/activerecord/test/cases/associations/eager_test.rb b/activerecord/test/cases/associations/eager_test.rb index 9afe6a893c..9a042c74db 100644 --- a/activerecord/test/cases/associations/eager_test.rb +++ b/activerecord/test/cases/associations/eager_test.rb @@ -427,7 +427,7 @@ class EagerAssociationTest < ActiveRecord::TestCase def test_eager_association_loading_with_belongs_to_and_order_string_with_quoted_table_name quoted_posts_id = Comment.connection.quote_table_name("posts") + "." + Comment.connection.quote_column_name("id") assert_nothing_raised do - Comment.includes(:post).references(:posts).order(quoted_posts_id) + Comment.includes(:post).references(:posts).order(Arel.sql(quoted_posts_id)) end end @@ -874,14 +874,14 @@ class EagerAssociationTest < ActiveRecord::TestCase posts(:thinking, :sti_comments), Post.all.merge!( includes: [:author, :comments], where: { "authors.name" => "David" }, - order: "UPPER(posts.title)", limit: 2, offset: 1 + order: Arel.sql("UPPER(posts.title)"), limit: 2, offset: 1 ).to_a ) assert_equal( posts(:sti_post_and_comments, :sti_comments), Post.all.merge!( includes: [:author, :comments], where: { "authors.name" => "David" }, - order: "UPPER(posts.title) DESC", limit: 2, offset: 1 + order: Arel.sql("UPPER(posts.title) DESC"), limit: 2, offset: 1 ).to_a ) end @@ -891,14 +891,14 @@ class EagerAssociationTest < ActiveRecord::TestCase posts(:thinking, :sti_comments), Post.all.merge!( includes: [:author, :comments], where: { "authors.name" => "David" }, - order: ["UPPER(posts.title)", "posts.id"], limit: 2, offset: 1 + order: [Arel.sql("UPPER(posts.title)"), "posts.id"], limit: 2, offset: 1 ).to_a ) assert_equal( posts(:sti_post_and_comments, :sti_comments), Post.all.merge!( includes: [:author, :comments], where: { "authors.name" => "David" }, - order: ["UPPER(posts.title) DESC", "posts.id"], limit: 2, offset: 1 + order: [Arel.sql("UPPER(posts.title) DESC"), "posts.id"], limit: 2, offset: 1 ).to_a ) end diff --git a/activerecord/test/cases/associations/has_many_associations_test.rb b/activerecord/test/cases/associations/has_many_associations_test.rb index 6bd11a5d81..4ca11af791 100644 --- a/activerecord/test/cases/associations/has_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -831,7 +831,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end def test_find_scoped_grouped_having - assert_equal 1, authors(:david).popular_grouped_posts.length + assert_equal 2, authors(:david).popular_grouped_posts.length assert_equal 0, authors(:mary).popular_grouped_posts.length end diff --git a/activerecord/test/cases/associations/inverse_associations_test.rb b/activerecord/test/cases/associations/inverse_associations_test.rb index e13cf93dcf..c0d328ca8a 100644 --- a/activerecord/test/cases/associations/inverse_associations_test.rb +++ b/activerecord/test/cases/associations/inverse_associations_test.rb @@ -484,7 +484,10 @@ class InverseHasManyTests < ActiveRecord::TestCase def test_raise_record_not_found_error_when_no_ids_are_passed man = Man.create! - assert_raise(ActiveRecord::RecordNotFound) { man.interests.find() } + exception = assert_raise(ActiveRecord::RecordNotFound) { man.interests.load.find() } + + assert_equal exception.model, "Interest" + assert_equal exception.primary_key, "id" end def test_trying_to_use_inverses_that_dont_exist_should_raise_an_error @@ -672,6 +675,16 @@ class InversePolymorphicBelongsToTests < ActiveRecord::TestCase assert_equal old_inversed_man.object_id, new_inversed_man.object_id end + def test_inversed_instance_should_not_be_reloaded_after_stale_state_changed_with_validation + face = Face.new man: Man.new + + old_inversed_man = face.man + face.save! + new_inversed_man = face.man + + assert_equal old_inversed_man.object_id, new_inversed_man.object_id + end + def test_should_not_try_to_set_inverse_instances_when_the_inverse_is_a_has_many i = interests(:llama_wrangling) m = i.polymorphic_man diff --git a/activerecord/test/cases/associations/nested_through_associations_test.rb b/activerecord/test/cases/associations/nested_through_associations_test.rb index 4cf5a9ffc4..65d30d011b 100644 --- a/activerecord/test/cases/associations/nested_through_associations_test.rb +++ b/activerecord/test/cases/associations/nested_through_associations_test.rb @@ -579,6 +579,28 @@ class NestedThroughAssociationsTest < ActiveRecord::TestCase assert !c.post_taggings.empty? end + def test_polymorphic_has_many_through_when_through_association_has_not_loaded + cake_designer = CakeDesigner.create!(chef: Chef.new) + drink_designer = DrinkDesigner.create!(chef: Chef.new) + department = Department.create!(chefs: [cake_designer.chef, drink_designer.chef]) + Hotel.create!(departments: [department]) + hotel = Hotel.includes(:cake_designers, :drink_designers).take + + assert_equal [cake_designer], hotel.cake_designers + assert_equal [drink_designer], hotel.drink_designers + end + + def test_polymorphic_has_many_through_when_through_association_has_already_loaded + cake_designer = CakeDesigner.create!(chef: Chef.new) + drink_designer = DrinkDesigner.create!(chef: Chef.new) + department = Department.create!(chefs: [cake_designer.chef, drink_designer.chef]) + Hotel.create!(departments: [department]) + hotel = Hotel.includes(:chefs, :cake_designers, :drink_designers).take + + assert_equal [cake_designer], hotel.cake_designers + assert_equal [drink_designer], hotel.drink_designers + end + def test_polymorphic_has_many_through_joined_different_table_twice cake_designer = CakeDesigner.create!(chef: Chef.new) drink_designer = DrinkDesigner.create!(chef: Chef.new) diff --git a/activerecord/test/cases/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb index 2f42684212..c48f7d3518 100644 --- a/activerecord/test/cases/attribute_methods_test.rb +++ b/activerecord/test/cases/attribute_methods_test.rb @@ -200,12 +200,7 @@ class AttributeMethodsTest < ActiveRecord::TestCase if current_adapter?(:Mysql2Adapter) test "read attributes_before_type_cast on a boolean" do bool = Boolean.create!("value" => false) - if RUBY_PLATFORM.include?("java") - # JRuby will return the value before typecast as string. - assert_equal "0", bool.reload.attributes_before_type_cast["value"] - else - assert_equal 0, bool.reload.attributes_before_type_cast["value"] - end + assert_equal 0, bool.reload.attributes_before_type_cast["value"] end end diff --git a/activerecord/test/cases/attribute_set_test.rb b/activerecord/test/cases/attribute_set_test.rb deleted file mode 100644 index 8be77ed88f..0000000000 --- a/activerecord/test/cases/attribute_set_test.rb +++ /dev/null @@ -1,255 +0,0 @@ -# frozen_string_literal: true - -require "cases/helper" - -module ActiveRecord - class AttributeSetTest < ActiveRecord::TestCase - test "building a new set from raw attributes" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) - attributes = builder.build_from_database(foo: "1.1", bar: "2.2") - - assert_equal 1, attributes[:foo].value - assert_equal 2.2, attributes[:bar].value - assert_equal :foo, attributes[:foo].name - assert_equal :bar, attributes[:bar].name - end - - test "building with custom types" do - builder = AttributeSet::Builder.new(foo: Type::Float.new) - attributes = builder.build_from_database({ foo: "3.3", bar: "4.4" }, { bar: Type::Integer.new }) - - assert_equal 3.3, attributes[:foo].value - assert_equal 4, attributes[:bar].value - end - - test "[] returns a null object" do - builder = AttributeSet::Builder.new(foo: Type::Float.new) - attributes = builder.build_from_database(foo: "3.3") - - assert_equal "3.3", attributes[:foo].value_before_type_cast - assert_nil attributes[:bar].value_before_type_cast - assert_equal :bar, attributes[:bar].name - end - - test "duping creates a new hash, but does not dup the attributes" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::String.new) - attributes = builder.build_from_database(foo: 1, bar: "foo") - - # Ensure the type cast value is cached - attributes[:foo].value - attributes[:bar].value - - duped = attributes.dup - duped.write_from_database(:foo, 2) - duped[:bar].value << "bar" - - assert_equal 1, attributes[:foo].value - assert_equal 2, duped[:foo].value - assert_equal "foobar", attributes[:bar].value - assert_equal "foobar", duped[:bar].value - end - - test "deep_duping creates a new hash and dups each attribute" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::String.new) - attributes = builder.build_from_database(foo: 1, bar: "foo") - - # Ensure the type cast value is cached - attributes[:foo].value - attributes[:bar].value - - duped = attributes.deep_dup - duped.write_from_database(:foo, 2) - duped[:bar].value << "bar" - - assert_equal 1, attributes[:foo].value - assert_equal 2, duped[:foo].value - assert_equal "foo", attributes[:bar].value - assert_equal "foobar", duped[:bar].value - end - - test "freezing cloned set does not freeze original" do - attributes = AttributeSet.new({}) - clone = attributes.clone - - clone.freeze - - assert clone.frozen? - assert_not attributes.frozen? - end - - test "to_hash returns a hash of the type cast values" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) - attributes = builder.build_from_database(foo: "1.1", bar: "2.2") - - assert_equal({ foo: 1, bar: 2.2 }, attributes.to_hash) - assert_equal({ foo: 1, bar: 2.2 }, attributes.to_h) - end - - test "to_hash maintains order" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) - attributes = builder.build_from_database(foo: "2.2", bar: "3.3") - - attributes[:bar] - hash = attributes.to_h - - assert_equal [[:foo, 2], [:bar, 3.3]], hash.to_a - end - - test "values_before_type_cast" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Integer.new) - attributes = builder.build_from_database(foo: "1.1", bar: "2.2") - - assert_equal({ foo: "1.1", bar: "2.2" }, attributes.values_before_type_cast) - end - - test "known columns are built with uninitialized attributes" do - attributes = attributes_with_uninitialized_key - assert attributes[:foo].initialized? - assert_not attributes[:bar].initialized? - end - - test "uninitialized attributes are not included in the attributes hash" do - attributes = attributes_with_uninitialized_key - assert_equal({ foo: 1 }, attributes.to_hash) - end - - test "uninitialized attributes are not included in keys" do - attributes = attributes_with_uninitialized_key - assert_equal [:foo], attributes.keys - end - - test "uninitialized attributes return false for key?" do - attributes = attributes_with_uninitialized_key - assert attributes.key?(:foo) - assert_not attributes.key?(:bar) - end - - test "unknown attributes return false for key?" do - attributes = attributes_with_uninitialized_key - assert_not attributes.key?(:wibble) - end - - test "fetch_value returns the value for the given initialized attribute" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) - attributes = builder.build_from_database(foo: "1.1", bar: "2.2") - - assert_equal 1, attributes.fetch_value(:foo) - assert_equal 2.2, attributes.fetch_value(:bar) - end - - test "fetch_value returns nil for unknown attributes" do - attributes = attributes_with_uninitialized_key - assert_nil attributes.fetch_value(:wibble) { "hello" } - end - - test "fetch_value returns nil for unknown attributes when types has a default" do - types = Hash.new(Type::Value.new) - builder = AttributeSet::Builder.new(types) - attributes = builder.build_from_database - - assert_nil attributes.fetch_value(:wibble) { "hello" } - end - - test "fetch_value uses the given block for uninitialized attributes" do - attributes = attributes_with_uninitialized_key - value = attributes.fetch_value(:bar) { |n| n.to_s + "!" } - assert_equal "bar!", value - end - - test "fetch_value returns nil for uninitialized attributes if no block is given" do - attributes = attributes_with_uninitialized_key - assert_nil attributes.fetch_value(:bar) - end - - test "the primary_key is always initialized" do - builder = AttributeSet::Builder.new({ foo: Type::Integer.new }, :foo) - attributes = builder.build_from_database - - assert attributes.key?(:foo) - assert_equal [:foo], attributes.keys - assert attributes[:foo].initialized? - end - - class MyType - def cast(value) - return if value.nil? - value + " from user" - end - - def deserialize(value) - return if value.nil? - value + " from database" - end - - def assert_valid_value(*) - end - end - - test "write_from_database sets the attribute with database typecasting" do - builder = AttributeSet::Builder.new(foo: MyType.new) - attributes = builder.build_from_database - - assert_nil attributes.fetch_value(:foo) - - attributes.write_from_database(:foo, "value") - - assert_equal "value from database", attributes.fetch_value(:foo) - end - - test "write_from_user sets the attribute with user typecasting" do - builder = AttributeSet::Builder.new(foo: MyType.new) - attributes = builder.build_from_database - - assert_nil attributes.fetch_value(:foo) - - attributes.write_from_user(:foo, "value") - - assert_equal "value from user", attributes.fetch_value(:foo) - end - - def attributes_with_uninitialized_key - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) - builder.build_from_database(foo: "1.1") - end - - test "freezing doesn't prevent the set from materializing" do - builder = AttributeSet::Builder.new(foo: Type::String.new) - attributes = builder.build_from_database(foo: "1") - - attributes.freeze - assert_equal({ foo: "1" }, attributes.to_hash) - end - - test "#accessed_attributes returns only attributes which have been read" do - builder = AttributeSet::Builder.new(foo: Type::Value.new, bar: Type::Value.new) - attributes = builder.build_from_database(foo: "1", bar: "2") - - assert_equal [], attributes.accessed - - attributes.fetch_value(:foo) - - assert_equal [:foo], attributes.accessed - end - - test "#map returns a new attribute set with the changes applied" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Integer.new) - attributes = builder.build_from_database(foo: "1", bar: "2") - new_attributes = attributes.map do |attr| - attr.with_cast_value(attr.value + 1) - end - - assert_equal 2, new_attributes.fetch_value(:foo) - assert_equal 3, new_attributes.fetch_value(:bar) - end - - test "comparison for equality is correctly implemented" do - builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Integer.new) - attributes = builder.build_from_database(foo: "1", bar: "2") - attributes2 = builder.build_from_database(foo: "1", bar: "2") - attributes3 = builder.build_from_database(foo: "2", bar: "2") - - assert_equal attributes, attributes2 - assert_not_equal attributes2, attributes3 - end - end -end diff --git a/activerecord/test/cases/attribute_test.rb b/activerecord/test/cases/attribute_test.rb deleted file mode 100644 index 1731e7926e..0000000000 --- a/activerecord/test/cases/attribute_test.rb +++ /dev/null @@ -1,255 +0,0 @@ -# frozen_string_literal: true - -require "cases/helper" - -module ActiveRecord - class AttributeTest < ActiveRecord::TestCase - setup do - @type = Minitest::Mock.new - end - - teardown do - assert @type.verify - end - - test "from_database + read type casts from database" do - @type.expect(:deserialize, "type cast from database", ["a value"]) - attribute = Attribute.from_database(nil, "a value", @type) - - type_cast_value = attribute.value - - assert_equal "type cast from database", type_cast_value - end - - test "from_user + read type casts from user" do - @type.expect(:cast, "type cast from user", ["a value"]) - attribute = Attribute.from_user(nil, "a value", @type) - - type_cast_value = attribute.value - - assert_equal "type cast from user", type_cast_value - end - - test "reading memoizes the value" do - @type.expect(:deserialize, "from the database", ["whatever"]) - attribute = Attribute.from_database(nil, "whatever", @type) - - type_cast_value = attribute.value - second_read = attribute.value - - assert_equal "from the database", type_cast_value - assert_same type_cast_value, second_read - end - - test "reading memoizes falsy values" do - @type.expect(:deserialize, false, ["whatever"]) - attribute = Attribute.from_database(nil, "whatever", @type) - - attribute.value - attribute.value - end - - test "read_before_typecast returns the given value" do - attribute = Attribute.from_database(nil, "raw value", @type) - - raw_value = attribute.value_before_type_cast - - assert_equal "raw value", raw_value - end - - test "from_database + read_for_database type casts to and from database" do - @type.expect(:deserialize, "read from database", ["whatever"]) - @type.expect(:serialize, "ready for database", ["read from database"]) - attribute = Attribute.from_database(nil, "whatever", @type) - - serialize = attribute.value_for_database - - assert_equal "ready for database", serialize - end - - test "from_user + read_for_database type casts from the user to the database" do - @type.expect(:cast, "read from user", ["whatever"]) - @type.expect(:serialize, "ready for database", ["read from user"]) - attribute = Attribute.from_user(nil, "whatever", @type) - - serialize = attribute.value_for_database - - assert_equal "ready for database", serialize - end - - test "duping dups the value" do - @type.expect(:deserialize, "type cast".dup, ["a value"]) - attribute = Attribute.from_database(nil, "a value", @type) - - value_from_orig = attribute.value - value_from_clone = attribute.dup.value - value_from_orig << " foo" - - assert_equal "type cast foo", value_from_orig - assert_equal "type cast", value_from_clone - end - - test "duping does not dup the value if it is not dupable" do - @type.expect(:deserialize, false, ["a value"]) - attribute = Attribute.from_database(nil, "a value", @type) - - assert_same attribute.value, attribute.dup.value - end - - test "duping does not eagerly type cast if we have not yet type cast" do - attribute = Attribute.from_database(nil, "a value", @type) - attribute.dup - end - - class MyType - def cast(value) - value + " from user" - end - - def deserialize(value) - value + " from database" - end - - def assert_valid_value(*) - end - end - - test "with_value_from_user returns a new attribute with the value from the user" do - old = Attribute.from_database(nil, "old", MyType.new) - new = old.with_value_from_user("new") - - assert_equal "old from database", old.value - assert_equal "new from user", new.value - end - - test "with_value_from_database returns a new attribute with the value from the database" do - old = Attribute.from_user(nil, "old", MyType.new) - new = old.with_value_from_database("new") - - assert_equal "old from user", old.value - assert_equal "new from database", new.value - end - - test "uninitialized attributes yield their name if a block is given to value" do - block = proc { |name| name.to_s + "!" } - foo = Attribute.uninitialized(:foo, nil) - bar = Attribute.uninitialized(:bar, nil) - - assert_equal "foo!", foo.value(&block) - assert_equal "bar!", bar.value(&block) - end - - test "uninitialized attributes have no value" do - assert_nil Attribute.uninitialized(:foo, nil).value - end - - test "attributes equal other attributes with the same constructor arguments" do - first = Attribute.from_database(:foo, 1, Type::Integer.new) - second = Attribute.from_database(:foo, 1, Type::Integer.new) - assert_equal first, second - end - - test "attributes do not equal attributes with different names" do - first = Attribute.from_database(:foo, 1, Type::Integer.new) - second = Attribute.from_database(:bar, 1, Type::Integer.new) - assert_not_equal first, second - end - - test "attributes do not equal attributes with different types" do - first = Attribute.from_database(:foo, 1, Type::Integer.new) - second = Attribute.from_database(:foo, 1, Type::Float.new) - assert_not_equal first, second - end - - test "attributes do not equal attributes with different values" do - first = Attribute.from_database(:foo, 1, Type::Integer.new) - second = Attribute.from_database(:foo, 2, Type::Integer.new) - assert_not_equal first, second - end - - test "attributes do not equal attributes of other classes" do - first = Attribute.from_database(:foo, 1, Type::Integer.new) - second = Attribute.from_user(:foo, 1, Type::Integer.new) - assert_not_equal first, second - end - - test "an attribute has not been read by default" do - attribute = Attribute.from_database(:foo, 1, Type::Value.new) - assert_not attribute.has_been_read? - end - - test "an attribute has been read when its value is calculated" do - attribute = Attribute.from_database(:foo, 1, Type::Value.new) - attribute.value - assert attribute.has_been_read? - end - - test "an attribute is not changed if it hasn't been assigned or mutated" do - attribute = Attribute.from_database(:foo, 1, Type::Value.new) - - refute attribute.changed? - end - - test "an attribute is changed if it's been assigned a new value" do - attribute = Attribute.from_database(:foo, 1, Type::Value.new) - changed = attribute.with_value_from_user(2) - - assert changed.changed? - end - - test "an attribute is not changed if it's assigned the same value" do - attribute = Attribute.from_database(:foo, 1, Type::Value.new) - unchanged = attribute.with_value_from_user(1) - - refute unchanged.changed? - end - - test "an attribute can not be mutated if it has not been read, - and skips expensive calculations" do - type_which_raises_from_all_methods = Object.new - attribute = Attribute.from_database(:foo, "bar", type_which_raises_from_all_methods) - - assert_not attribute.changed_in_place? - end - - test "an attribute is changed if it has been mutated" do - attribute = Attribute.from_database(:foo, "bar", Type::String.new) - attribute.value << "!" - - assert attribute.changed_in_place? - assert attribute.changed? - end - - test "an attribute can forget its changes" do - attribute = Attribute.from_database(:foo, "bar", Type::String.new) - changed = attribute.with_value_from_user("foo") - forgotten = changed.forgetting_assignment - - assert changed.changed? # sanity check - refute forgotten.changed? - end - - test "with_value_from_user validates the value" do - type = Type::Value.new - type.define_singleton_method(:assert_valid_value) do |value| - if value == 1 - raise ArgumentError - end - end - - attribute = Attribute.from_database(:foo, 1, type) - assert_equal 1, attribute.value - assert_equal 2, attribute.with_value_from_user(2).value - assert_raises ArgumentError do - attribute.with_value_from_user(1) - end - end - - test "with_type preserves mutations" do - attribute = Attribute.from_database(:foo, "".dup, Type::Value.new) - attribute.value << "1" - - assert_equal 1, attribute.with_type(Type::Integer.new).value - end - end -end diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb index f0ef522515..3497f6aae4 100644 --- a/activerecord/test/cases/base_test.rb +++ b/activerecord/test/cases/base_test.rb @@ -891,11 +891,9 @@ class BasicsTest < ActiveRecord::TestCase assert_equal 2147483648, company.rating end - unless current_adapter?(:SQLite3Adapter) - def test_bignum_pk - company = Company.create!(id: 2147483648, name: "foo") - assert_equal company, Company.find(company.id) - end + def test_bignum_pk + company = Company.create!(id: 2147483648, name: "foo") + assert_equal company, Company.find(company.id) end # TODO: extend defaults tests to other databases! @@ -1476,4 +1474,25 @@ class BasicsTest < ActiveRecord::TestCase assert_equal(%w(first_name last_name), Developer.ignored_columns) assert_equal(%w(first_name last_name), SymbolIgnoredDeveloper.ignored_columns) end + + test "when #reload called, ignored columns' attribute methods are not defined" do + developer = Developer.create!(name: "Developer") + refute developer.respond_to?(:first_name) + refute developer.respond_to?(:first_name=) + + developer.reload + + refute developer.respond_to?(:first_name) + refute developer.respond_to?(:first_name=) + end + + test "ignored columns not included in SELECT" do + query = Developer.all.to_sql.downcase + + # ignored column + refute query.include?("first_name") + + # regular column + assert query.include?("name") + end end diff --git a/activerecord/test/cases/calculations_test.rb b/activerecord/test/cases/calculations_test.rb index 66bc14b5ab..55b50e4f84 100644 --- a/activerecord/test/cases/calculations_test.rb +++ b/activerecord/test/cases/calculations_test.rb @@ -663,14 +663,14 @@ class CalculationsTest < ActiveRecord::TestCase end def test_pluck_with_selection_clause - assert_equal [50, 53, 55, 60], Account.pluck("DISTINCT credit_limit").sort - assert_equal [50, 53, 55, 60], Account.pluck("DISTINCT accounts.credit_limit").sort - assert_equal [50, 53, 55, 60], Account.pluck("DISTINCT(credit_limit)").sort + assert_equal [50, 53, 55, 60], Account.pluck(Arel.sql("DISTINCT credit_limit")).sort + assert_equal [50, 53, 55, 60], Account.pluck(Arel.sql("DISTINCT accounts.credit_limit")).sort + assert_equal [50, 53, 55, 60], Account.pluck(Arel.sql("DISTINCT(credit_limit)")).sort # MySQL returns "SUM(DISTINCT(credit_limit))" as the column name unless # an alias is provided. Without the alias, the column cannot be found # and properly typecast. - assert_equal [50 + 53 + 55 + 60], Account.pluck("SUM(DISTINCT(credit_limit)) as credit_limit") + assert_equal [50 + 53 + 55 + 60], Account.pluck(Arel.sql("SUM(DISTINCT(credit_limit)) as credit_limit")) end def test_plucks_with_ids @@ -772,7 +772,7 @@ class CalculationsTest < ActiveRecord::TestCase companies = Company.order(:name).limit(3).load assert_queries 1 do - assert_equal ["37signals", "Apex", "Ex Nihilo"], companies.pluck("DISTINCT name") + assert_equal ["37signals", "Apex", "Ex Nihilo"], companies.pluck(Arel.sql("DISTINCT name")) end end diff --git a/activerecord/test/cases/collection_cache_key_test.rb b/activerecord/test/cases/collection_cache_key_test.rb index c137693211..19d6464a22 100644 --- a/activerecord/test/cases/collection_cache_key_test.rb +++ b/activerecord/test/cases/collection_cache_key_test.rb @@ -73,6 +73,16 @@ module ActiveRecord assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3 end + test "cache_key for relation with includes" do + comments = Comment.includes(:post).where("posts.type": "Post") + assert_match(/\Acomments\/query-(\h+)-(\d+)-(\d+)\z/, comments.cache_key) + end + + test "cache_key for loaded relation with includes" do + comments = Comment.includes(:post).where("posts.type": "Post").load + assert_match(/\Acomments\/query-(\h+)-(\d+)-(\d+)\z/, comments.cache_key) + end + test "it triggers at most one query" do developers = Developer.where(name: "David") diff --git a/activerecord/test/cases/connection_adapters/connection_handler_test.rb b/activerecord/test/cases/connection_adapters/connection_handler_test.rb index 74d0ed348e..cae74a2b9b 100644 --- a/activerecord/test/cases/connection_adapters/connection_handler_test.rb +++ b/activerecord/test/cases/connection_adapters/connection_handler_test.rb @@ -1,10 +1,15 @@ # frozen_string_literal: true require "cases/helper" +require "models/person" module ActiveRecord module ConnectionAdapters class ConnectionHandlerTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + fixtures :people + def setup @handler = ConnectionHandler.new @spec_name = "primary" @@ -139,6 +144,33 @@ module ActiveRecord rd.close end + def test_forked_child_doesnt_mangle_parent_connection + object_id = ActiveRecord::Base.connection.object_id + assert ActiveRecord::Base.connection.active? + + rd, wr = IO.pipe + rd.binmode + wr.binmode + + pid = fork { + rd.close + if ActiveRecord::Base.connection.active? + wr.write Marshal.dump ActiveRecord::Base.connection.object_id + end + wr.close + + exit # allow finalizers to run + } + + wr.close + + Process.waitpid pid + assert_not_equal object_id, Marshal.load(rd.read) + rd.close + + assert_equal 3, ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM people") + end + def test_retrieve_connection_pool_copies_schema_cache_from_ancestor_pool @pool.schema_cache = @pool.connection.schema_cache @pool.schema_cache.add("posts") diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index 2bfe490602..1e08cc74dc 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -156,6 +156,53 @@ module ActiveRecord @pool.connections.each { |conn| conn.close if conn.in_use? } end + def test_flush + idle_conn = @pool.checkout + recent_conn = @pool.checkout + active_conn = @pool.checkout + + @pool.checkin idle_conn + @pool.checkin recent_conn + + assert_equal 3, @pool.connections.length + + def idle_conn.seconds_idle + 1000 + end + + @pool.flush(30) + + assert_equal 2, @pool.connections.length + + assert_equal [recent_conn, active_conn].sort_by(&:__id__), @pool.connections.sort_by(&:__id__) + ensure + @pool.checkin active_conn + end + + def test_flush_bang + idle_conn = @pool.checkout + recent_conn = @pool.checkout + active_conn = @pool.checkout + _dead_conn = Thread.new { @pool.checkout }.join + + @pool.checkin idle_conn + @pool.checkin recent_conn + + assert_equal 4, @pool.connections.length + + def idle_conn.seconds_idle + 1000 + end + + @pool.flush! + + assert_equal 1, @pool.connections.length + + assert_equal [active_conn].sort_by(&:__id__), @pool.connections.sort_by(&:__id__) + ensure + @pool.checkin active_conn + end + def test_remove_connection conn = @pool.checkout assert conn.in_use? @@ -205,6 +252,14 @@ module ActiveRecord end.join end + def test_checkout_order_is_lifo + conn1 = @pool.checkout + conn2 = @pool.checkout + @pool.checkin conn1 + @pool.checkin conn2 + assert_equal [conn2, conn1], 2.times.map { @pool.checkout } + end + # The connection pool is "fair" if threads waiting for # connections receive them in the order in which they began # waiting. This ensures that we don't timeout one HTTP request diff --git a/activerecord/test/cases/enum_test.rb b/activerecord/test/cases/enum_test.rb index 78cb89ccc5..7cda712112 100644 --- a/activerecord/test/cases/enum_test.rb +++ b/activerecord/test/cases/enum_test.rb @@ -308,6 +308,24 @@ class EnumTest < ActiveRecord::TestCase end end + test "reserved enum values for relation" do + relation_method_samples = [ + :records, + :to_ary, + :scope_for_create + ] + + relation_method_samples.each do |value| + e = assert_raises(ArgumentError, "enum value `#{value}` should not be allowed") do + Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum category: [:other, value] + end + end + assert_match(/You tried to define an enum named .* on the model/, e.message) + end + end + test "overriding enum method should not raise" do assert_nothing_raised do Class.new(ActiveRecord::Base) do diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index d8bc917e7f..e936c56ab8 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -120,6 +120,21 @@ class FinderTest < ActiveRecord::TestCase assert_equal "The Fourth Topic of the day", records[2].title end + def test_find_with_ids_with_no_id_passed + exception = assert_raises(ActiveRecord::RecordNotFound) { Topic.find } + assert_equal exception.model, "Topic" + assert_equal exception.primary_key, "id" + end + + def test_find_with_ids_with_id_out_of_range + exception = assert_raises(ActiveRecord::RecordNotFound) do + Topic.find("9999999999999999999999999999999") + end + + assert_equal exception.model, "Topic" + assert_equal exception.primary_key, "id" + end + def test_find_passing_active_record_object_is_not_permitted assert_raises(ArgumentError) do Topic.find(Topic.last) @@ -239,7 +254,7 @@ class FinderTest < ActiveRecord::TestCase # Ensure +exists?+ runs without an error by excluding order value. def test_exists_with_order - assert_equal true, Topic.order("invalid sql here").exists? + assert_equal true, Topic.order(Arel.sql("invalid sql here")).exists? end def test_exists_with_joins @@ -652,7 +667,7 @@ class FinderTest < ActiveRecord::TestCase def test_last_with_irreversible_order assert_raises(ActiveRecord::IrreversibleOrderError) do - Topic.order("coalesce(author_name, title)").last + Topic.order(Arel.sql("coalesce(author_name, title)")).last end end diff --git a/activerecord/test/cases/invertible_migration_test.rb b/activerecord/test/cases/invertible_migration_test.rb index 60c628511f..ebe0b0aa87 100644 --- a/activerecord/test/cases/invertible_migration_test.rb +++ b/activerecord/test/cases/invertible_migration_test.rb @@ -161,6 +161,15 @@ module ActiveRecord end end + class UpOnlyMigration < SilentMigration + def change + add_column :horses, :oldie, :integer, default: 0 + up_only { execute "update horses set oldie = 1" } + end + end + + self.use_transactional_tests = false + setup do @verbose_was, ActiveRecord::Migration.verbose = ActiveRecord::Migration.verbose, false end @@ -378,5 +387,23 @@ module ActiveRecord "horses_index_named index should not exist" end end + + def test_up_only + InvertibleMigration.new.migrate(:up) + horse1 = Horse.create + # populates existing horses with oldie = 1 but new ones have default 0 + UpOnlyMigration.new.migrate(:up) + Horse.reset_column_information + horse1.reload + horse2 = Horse.create + + assert 1, horse1.oldie # created before migration + assert 0, horse2.oldie # created after migration + + UpOnlyMigration.new.migrate(:down) # should be no error + connection = ActiveRecord::Base.connection + assert !connection.column_exists?(:horses, :oldie) + Horse.reset_column_information + end end end diff --git a/activerecord/test/cases/json_attribute_test.rb b/activerecord/test/cases/json_attribute_test.rb index 63f3c77fc3..afc39d0420 100644 --- a/activerecord/test/cases/json_attribute_test.rb +++ b/activerecord/test/cases/json_attribute_test.rb @@ -19,14 +19,14 @@ class JsonAttributeTest < ActiveRecord::TestCase def setup super @connection.create_table("json_data_type") do |t| - t.text "payload" - t.text "settings" + t.string "payload" + t.string "settings" end end private def column_type - :text + :string end def klass diff --git a/activerecord/test/cases/json_shared_test_cases.rb b/activerecord/test/cases/json_shared_test_cases.rb index a71485982c..b0c0f2c283 100644 --- a/activerecord/test/cases/json_shared_test_cases.rb +++ b/activerecord/test/cases/json_shared_test_cases.rb @@ -23,25 +23,23 @@ module JSONSharedTestCases def test_column column = klass.columns_hash["payload"] assert_equal column_type, column.type - assert_equal column_type.to_s, column.sql_type + assert_type_match column_type, column.sql_type type = klass.type_for_attribute("payload") assert_not type.binary? end def test_change_table_supports_json - skip unless @connection.supports_json? @connection.change_table("json_data_type") do |t| t.public_send column_type, "users" end klass.reset_column_information column = klass.columns_hash["users"] assert_equal column_type, column.type - assert_equal column_type.to_s, column.sql_type + assert_type_match column_type, column.sql_type end def test_schema_dumping - skip unless @connection.supports_json? output = dump_table_schema("json_data_type") assert_match(/t\.#{column_type}\s+"settings"/, output) end @@ -68,26 +66,26 @@ module JSONSharedTestCases end def test_rewrite - @connection.execute(%q|insert into json_data_type (payload) VALUES ('{"k":"v"}')|) + @connection.execute(insert_statement_per_database('{"k":"v"}')) x = klass.first x.payload = { '"a\'' => "b" } assert x.save! end def test_select - @connection.execute(%q|insert into json_data_type (payload) VALUES ('{"k":"v"}')|) + @connection.execute(insert_statement_per_database('{"k":"v"}')) x = klass.first assert_equal({ "k" => "v" }, x.payload) end def test_select_multikey - @connection.execute(%q|insert into json_data_type (payload) VALUES ('{"k1":"v1", "k2":"v2", "k3":[1,2,3]}')|) + @connection.execute(insert_statement_per_database('{"k1":"v1", "k2":"v2", "k3":[1,2,3]}')) x = klass.first assert_equal({ "k1" => "v1", "k2" => "v2", "k3" => [1, 2, 3] }, x.payload) end def test_null_json - @connection.execute("insert into json_data_type (payload) VALUES(null)") + @connection.execute(insert_statement_per_database("null")) x = klass.first assert_nil(x.payload) end @@ -109,13 +107,13 @@ module JSONSharedTestCases end def test_select_array_json_value - @connection.execute(%q|insert into json_data_type (payload) VALUES ('["v0",{"k1":"v1"}]')|) + @connection.execute(insert_statement_per_database('["v0",{"k1":"v1"}]')) x = klass.first assert_equal(["v0", { "k1" => "v1" }], x.payload) end def test_rewrite_array_json_value - @connection.execute(%q|insert into json_data_type (payload) VALUES ('["v0",{"k1":"v1"}]')|) + @connection.execute(insert_statement_per_database('["v0",{"k1":"v1"}]')) x = klass.first x.payload = ["v1", { "k2" => "v2" }, "v3"] assert x.save! @@ -255,4 +253,17 @@ module JSONSharedTestCases def klass JsonDataType end + + def assert_type_match(type, sql_type) + native_type = ActiveRecord::Base.connection.native_database_types[type][:name] + assert_match %r(\A#{native_type}\b), sql_type + end + + def insert_statement_per_database(values) + if current_adapter?(:OracleAdapter) + "insert into json_data_type (id, payload) VALUES (json_data_type_seq.nextval, '#{values}')" + else + "insert into json_data_type (payload) VALUES ('#{values}')" + end + end end diff --git a/activerecord/test/cases/locking_test.rb b/activerecord/test/cases/locking_test.rb index 6791d50940..e857180bd1 100644 --- a/activerecord/test/cases/locking_test.rb +++ b/activerecord/test/cases/locking_test.rb @@ -611,14 +611,12 @@ unless in_memory_db? end end - if current_adapter?(:PostgreSQLAdapter, :OracleAdapter) - def test_no_locks_no_wait - first, second = duel { Person.find 1 } - assert first.end > second.end - end - - private + def test_no_locks_no_wait + first, second = duel { Person.find 1 } + assert first.end > second.end + end + private def duel(zzz = 5) t0, t1, t2, t3 = nil, nil, nil, nil @@ -646,6 +644,5 @@ unless in_memory_db? assert t3 > t2 [t0.to_f..t1.to_f, t2.to_f..t3.to_f] end - end end end diff --git a/activerecord/test/cases/migration/change_schema_test.rb b/activerecord/test/cases/migration/change_schema_test.rb index 7b0644e9c0..38a906c8f5 100644 --- a/activerecord/test/cases/migration/change_schema_test.rb +++ b/activerecord/test/cases/migration/change_schema_test.rb @@ -264,19 +264,18 @@ module ActiveRecord t.column :foo, :timestamp end - klass = Class.new(ActiveRecord::Base) - klass.table_name = "testings" + column = connection.columns(:testings).find { |c| c.name == "foo" } - assert_equal :datetime, klass.columns_hash["foo"].type + assert_equal :datetime, column.type if current_adapter?(:PostgreSQLAdapter) - assert_equal "timestamp without time zone", klass.columns_hash["foo"].sql_type + assert_equal "timestamp without time zone", column.sql_type elsif current_adapter?(:Mysql2Adapter) - assert_equal "timestamp", klass.columns_hash["foo"].sql_type + assert_equal "timestamp", column.sql_type elsif current_adapter?(:OracleAdapter) - assert_equal "TIMESTAMP(6)", klass.columns_hash["foo"].sql_type + assert_equal "TIMESTAMP(6)", column.sql_type else - assert_equal klass.connection.type_to_sql("datetime"), klass.columns_hash["foo"].sql_type + assert_equal connection.type_to_sql("datetime"), column.sql_type end end diff --git a/activerecord/test/cases/migration/compatibility_test.rb b/activerecord/test/cases/migration/compatibility_test.rb index 2fef2f796e..cc2391f349 100644 --- a/activerecord/test/cases/migration/compatibility_test.rb +++ b/activerecord/test/cases/migration/compatibility_test.rb @@ -16,7 +16,7 @@ module ActiveRecord ActiveRecord::Migration.verbose = false connection.create_table :testings do |t| - t.column :foo, :string, limit: 100 + t.column :foo, :string, limit: 5 t.column :bar, :string, limit: 100 end end @@ -126,6 +126,25 @@ module ActiveRecord end assert_match(/LegacyMigration < ActiveRecord::Migration\[4\.2\]/, e.message) end + + if current_adapter?(:PostgreSQLAdapter) + class Testing < ActiveRecord::Base + end + + def test_legacy_change_column_with_null_executes_update + migration = Class.new(ActiveRecord::Migration[5.1]) { + def migrate(x) + change_column :testings, :foo, :string, limit: 10, null: false, default: "foobar" + end + }.new + + Testing.create! + ActiveRecord::Migrator.new(:up, [migration]).migrate + assert_equal ["foobar"], Testing.all.map(&:foo) + ensure + ActiveRecord::Base.clear_cache! + end + end end end end diff --git a/activerecord/test/cases/migration/foreign_key_test.rb b/activerecord/test/cases/migration/foreign_key_test.rb index 499d072de5..079be04946 100644 --- a/activerecord/test/cases/migration/foreign_key_test.rb +++ b/activerecord/test/cases/migration/foreign_key_test.rb @@ -227,6 +227,74 @@ if ActiveRecord::Base.connection.supports_foreign_keys? end end + if ActiveRecord::Base.connection.supports_validate_constraints? + def test_add_invalid_foreign_key + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", validate: false + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + refute fk.validated? + end + + def test_validate_foreign_key_infers_column + @connection.add_foreign_key :astronauts, :rockets, validate: false + refute @connection.foreign_keys("astronauts").first.validated? + + @connection.validate_foreign_key :astronauts, :rockets + assert @connection.foreign_keys("astronauts").first.validated? + end + + def test_validate_foreign_key_by_column + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", validate: false + refute @connection.foreign_keys("astronauts").first.validated? + + @connection.validate_foreign_key :astronauts, column: "rocket_id" + assert @connection.foreign_keys("astronauts").first.validated? + end + + def test_validate_foreign_key_by_symbol_column + @connection.add_foreign_key :astronauts, :rockets, column: :rocket_id, validate: false + refute @connection.foreign_keys("astronauts").first.validated? + + @connection.validate_foreign_key :astronauts, column: :rocket_id + assert @connection.foreign_keys("astronauts").first.validated? + end + + def test_validate_foreign_key_by_name + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk", validate: false + refute @connection.foreign_keys("astronauts").first.validated? + + @connection.validate_foreign_key :astronauts, name: "fancy_named_fk" + assert @connection.foreign_keys("astronauts").first.validated? + end + + def test_validate_foreign_non_existing_foreign_key_raises + assert_raises ArgumentError do + @connection.validate_foreign_key :astronauts, :rockets + end + end + + def test_validate_constraint_by_name + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk", validate: false + + @connection.validate_constraint :astronauts, "fancy_named_fk" + assert @connection.foreign_keys("astronauts").first.validated? + end + else + # Foreign key should still be created, but should not be invalid + def test_add_invalid_foreign_key + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", validate: false + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert fk.validated? + end + end + def test_schema_dumping @connection.add_foreign_key :astronauts, :rockets output = dump_table_schema "astronauts" diff --git a/activerecord/test/cases/migrator_test.rb b/activerecord/test/cases/migrator_test.rb index ee10be119c..1047ba1367 100644 --- a/activerecord/test/cases/migrator_test.rb +++ b/activerecord/test/cases/migrator_test.rb @@ -66,6 +66,26 @@ class MigratorTest < ActiveRecord::TestCase list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] ActiveRecord::Migrator.new(:up, list, 3).run end + + assert_raises(ActiveRecord::UnknownMigrationVersionError) do + list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] + ActiveRecord::Migrator.new(:up, list, -1).run + end + + assert_raises(ActiveRecord::UnknownMigrationVersionError) do + list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] + ActiveRecord::Migrator.new(:up, list, 0).run + end + + assert_raises(ActiveRecord::UnknownMigrationVersionError) do + list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] + ActiveRecord::Migrator.new(:up, list, 3).migrate + end + + assert_raises(ActiveRecord::UnknownMigrationVersionError) do + list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] + ActiveRecord::Migrator.new(:up, list, -1).migrate + end end def test_finds_migrations diff --git a/activerecord/test/cases/persistence_test.rb b/activerecord/test/cases/persistence_test.rb index 6cbe18cc8c..4cc66a2e49 100644 --- a/activerecord/test/cases/persistence_test.rb +++ b/activerecord/test/cases/persistence_test.rb @@ -70,7 +70,7 @@ class PersistenceTest < ActiveRecord::TestCase end def test_update_many - topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" }, nil => {} } + topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" } } updated = Topic.update(topic_data.keys, topic_data.values) assert_equal [1, 2], updated.map(&:id) @@ -78,10 +78,33 @@ class PersistenceTest < ActiveRecord::TestCase assert_equal "2 updated", Topic.find(2).content end + def test_update_many_with_duplicated_ids + updated = Topic.update([1, 1, 2], [ + { "content" => "1 duplicated" }, { "content" => "1 updated" }, { "content" => "2 updated" } + ]) + + assert_equal [1, 1, 2], updated.map(&:id) + assert_equal "1 updated", Topic.find(1).content + assert_equal "2 updated", Topic.find(2).content + end + + def test_update_many_with_invalid_id + topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" }, 99999 => {} } + + assert_raise(ActiveRecord::RecordNotFound) do + Topic.update(topic_data.keys, topic_data.values) + end + + assert_not_equal "1 updated", Topic.find(1).content + assert_not_equal "2 updated", Topic.find(2).content + end + def test_class_level_update_is_affected_by_scoping topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" } } - assert_equal [], Topic.where("1=0").scoping { Topic.update(topic_data.keys, topic_data.values) } + assert_raise(ActiveRecord::RecordNotFound) do + Topic.where("1=0").scoping { Topic.update(topic_data.keys, topic_data.values) } + end assert_not_equal "1 updated", Topic.find(1).content assert_not_equal "2 updated", Topic.find(2).content @@ -94,27 +117,31 @@ class PersistenceTest < ActiveRecord::TestCase end def test_delete_all_with_joins_and_where_part_is_hash - where_args = { toys: { name: "Bone" } } - count = Pet.joins(:toys).where(where_args).count + pets = Pet.joins(:toys).where(toys: { name: "Bone" }) + + assert_equal true, pets.exists? + assert_equal pets.count, pets.delete_all + end - assert_equal count, 1 - assert_equal count, Pet.joins(:toys).where(where_args).delete_all + def test_delete_all_with_joins_and_where_part_is_not_hash + pets = Pet.joins(:toys).where("toys.name = ?", "Bone") + + assert_equal true, pets.exists? + assert_equal pets.count, pets.delete_all end def test_delete_all_with_left_joins - where_args = { toys: { name: "Bone" } } - count = Pet.left_joins(:toys).where(where_args).count + pets = Pet.left_joins(:toys).where(toys: { name: "Bone" }) - assert_equal count, 1 - assert_equal count, Pet.left_joins(:toys).where(where_args).delete_all + assert_equal true, pets.exists? + assert_equal pets.count, pets.delete_all end - def test_delete_all_with_joins_and_where_part_is_not_hash - where_args = ["toys.name = ?", "Bone"] - count = Pet.joins(:toys).where(where_args).count + def test_delete_all_with_includes + pets = Pet.includes(:toys).where(toys: { name: "Bone" }) - assert_equal count, 1 - assert_equal count, Pet.joins(:toys).where(where_args).delete_all + assert_equal true, pets.exists? + assert_equal pets.count, pets.delete_all end def test_increment_attribute @@ -171,15 +198,25 @@ class PersistenceTest < ActiveRecord::TestCase end def test_destroy_many - clients = Client.all.merge!(order: "id").find([2, 3]) + clients = Client.find([2, 3]) assert_difference("Client.count", -2) do - destroyed = Client.destroy([2, 3, nil]).sort_by(&:id) + destroyed = Client.destroy([2, 3]) assert_equal clients, destroyed assert destroyed.all?(&:frozen?), "destroyed clients should be frozen" end end + def test_destroy_many_with_invalid_id + clients = Client.find([2, 3]) + + assert_raise(ActiveRecord::RecordNotFound) do + Client.destroy([2, 3, 99999]) + end + + assert_equal clients, Client.find([2, 3]) + end + def test_becomes assert_kind_of Reply, topics(:first).becomes(Reply) assert_equal "The First Topic", topics(:first).becomes(Reply).title @@ -469,10 +506,18 @@ class PersistenceTest < ActiveRecord::TestCase assert_raise(ActiveRecord::RecordNotFound) { Topic.find(topic.id) } end - def test_record_not_found_exception + def test_find_raises_record_not_found_exception assert_raise(ActiveRecord::RecordNotFound) { Topic.find(99999) } end + def test_update_raises_record_not_found_exception + assert_raise(ActiveRecord::RecordNotFound) { Topic.update(99999, approved: true) } + end + + def test_destroy_raises_record_not_found_exception + assert_raise(ActiveRecord::RecordNotFound) { Topic.destroy(99999) } + end + def test_update_all assert_equal Topic.count, Topic.update_all("content = 'bulk updated!'") assert_equal "bulk updated!", Topic.find(1).content @@ -496,17 +541,24 @@ class PersistenceTest < ActiveRecord::TestCase end def test_update_all_with_joins - where_args = { toys: { name: "Bone" } } - count = Pet.left_joins(:toys).where(where_args).count + pets = Pet.joins(:toys).where(toys: { name: "Bone" }) - assert_equal count, Pet.joins(:toys).where(where_args).update_all(name: "Bob") + assert_equal true, pets.exists? + assert_equal pets.count, pets.update_all(name: "Bob") end def test_update_all_with_left_joins - where_args = { toys: { name: "Bone" } } - count = Pet.left_joins(:toys).where(where_args).count + pets = Pet.left_joins(:toys).where(toys: { name: "Bone" }) - assert_equal count, Pet.left_joins(:toys).where(where_args).update_all(name: "Bob") + assert_equal true, pets.exists? + assert_equal pets.count, pets.update_all(name: "Bob") + end + + def test_update_all_with_includes + pets = Pet.includes(:toys).where(toys: { name: "Bone" }) + + assert_equal true, pets.exists? + assert_equal pets.count, pets.update_all(name: "Bob") end def test_update_all_with_non_standard_table_name @@ -927,7 +979,9 @@ class PersistenceTest < ActiveRecord::TestCase should_not_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world") Topic.find(1).replies << should_not_be_destroyed_reply - assert_nil Topic.where("1=0").scoping { Topic.destroy(1) } + assert_raise(ActiveRecord::RecordNotFound) do + Topic.where("1=0").scoping { Topic.destroy(1) } + end assert_nothing_raised { Topic.find(1) } assert_nothing_raised { Reply.find(should_not_be_destroyed_reply.id) } diff --git a/activerecord/test/cases/query_cache_test.rb b/activerecord/test/cases/query_cache_test.rb index 5cb537b623..46f90b0bca 100644 --- a/activerecord/test/cases/query_cache_test.rb +++ b/activerecord/test/cases/query_cache_test.rb @@ -452,6 +452,15 @@ class QueryCacheTest < ActiveRecord::TestCase end end + def test_query_cache_is_enabled_on_all_connection_pools + middleware { + ActiveRecord::Base.connection_handler.connection_pool_list.each do |pool| + assert pool.query_cache_enabled + assert pool.connection.query_cache_enabled + end + }.call({}) + end + private def middleware(&app) executor = Class.new(ActiveSupport::Executor) diff --git a/activerecord/test/cases/reaper_test.rb b/activerecord/test/cases/reaper_test.rb index 49170abe6f..6c7727ab1b 100644 --- a/activerecord/test/cases/reaper_test.rb +++ b/activerecord/test/cases/reaper_test.rb @@ -18,6 +18,7 @@ module ActiveRecord class FakePool attr_reader :reaped + attr_reader :flushed def initialize @reaped = false @@ -26,6 +27,10 @@ module ActiveRecord def reap @reaped = true end + + def flush + @flushed = true + end end # A reaper with nil time should never reap connections @@ -47,6 +52,7 @@ module ActiveRecord Thread.pass end assert fp.reaped + assert fp.flushed end def test_pool_has_reaper diff --git a/activerecord/test/cases/relation_test.rb b/activerecord/test/cases/relation_test.rb index fd5985ffe7..b424ca91de 100644 --- a/activerecord/test/cases/relation_test.rb +++ b/activerecord/test/cases/relation_test.rb @@ -68,7 +68,7 @@ module ActiveRecord relation = Relation.new(Post, Post.arel_table, Post.predicate_builder) left = relation.table[:id].eq(10) right = relation.table[:id].eq(10) - combine = left.and right + combine = left.or(right) relation.where! combine assert_equal({}, relation.where_values_hash) end @@ -274,18 +274,32 @@ module ActiveRecord assert_equal({ 2 => 1, 4 => 3, 5 => 1 }, authors(:david).posts.merge(posts_with_special_comments_with_ratings).count) end + def test_relation_merging_keeps_joining_order + authors = Author.where(id: 1) + posts = Post.joins(:author).merge(authors) + comments = Comment.joins(:post).merge(posts) + ratings = Rating.joins(:comment).merge(comments) + + assert_equal 3, ratings.count + end + class EnsureRoundTripTypeCasting < ActiveRecord::Type::Value def type :string end + def cast(value) + raise value unless value == "value from user" + "cast value" + end + def deserialize(value) raise value unless value == "type cast for database" "type cast from database" end def serialize(value) - raise value unless value == "value from user" + raise value unless value == "cast value" "type cast for database" end end diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index 72433d1e8e..675aafabda 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -195,6 +195,18 @@ class RelationTest < ActiveRecord::TestCase assert_equal(relation.map(&:post_count).sort, subquery.values.sort) end + def test_finding_with_subquery_with_eager_loading_in_from + relation = Comment.includes(:post).where("posts.type": "Post") + assert_equal relation.to_a, Comment.select("*").from(relation).to_a + assert_equal relation.to_a, Comment.select("subquery.*").from(relation).to_a + assert_equal relation.to_a, Comment.select("a.*").from(relation, :a).to_a + end + + def test_finding_with_subquery_with_eager_loading_in_where + relation = Comment.includes(:post).where("posts.type": "Post") + assert_equal relation.sort_by(&:id), Comment.where(id: relation).sort_by(&:id) + end + def test_finding_with_conditions assert_equal ["David"], Author.where(name: "David").map(&:name) assert_equal ["Mary"], Author.where(["name = ?", "Mary"]).map(&:name) @@ -238,7 +250,7 @@ class RelationTest < ActiveRecord::TestCase end def test_reverse_order_with_function - topics = Topic.order("length(title)").reverse_order + topics = Topic.order(Arel.sql("length(title)")).reverse_order assert_equal topics(:second).title, topics.first.title end @@ -248,24 +260,24 @@ class RelationTest < ActiveRecord::TestCase end def test_reverse_order_with_function_other_predicates - topics = Topic.order("author_name, length(title), id").reverse_order + topics = Topic.order(Arel.sql("author_name, length(title), id")).reverse_order assert_equal topics(:second).title, topics.first.title - topics = Topic.order("length(author_name), id, length(title)").reverse_order + topics = Topic.order(Arel.sql("length(author_name), id, length(title)")).reverse_order assert_equal topics(:fifth).title, topics.first.title end def test_reverse_order_with_multiargument_function assert_raises(ActiveRecord::IrreversibleOrderError) do - Topic.order("concat(author_name, title)").reverse_order + Topic.order(Arel.sql("concat(author_name, title)")).reverse_order end assert_raises(ActiveRecord::IrreversibleOrderError) do - Topic.order("concat(lower(author_name), title)").reverse_order + Topic.order(Arel.sql("concat(lower(author_name), title)")).reverse_order end assert_raises(ActiveRecord::IrreversibleOrderError) do - Topic.order("concat(author_name, lower(title))").reverse_order + Topic.order(Arel.sql("concat(author_name, lower(title))")).reverse_order end assert_raises(ActiveRecord::IrreversibleOrderError) do - Topic.order("concat(lower(author_name), title, length(title)").reverse_order + Topic.order(Arel.sql("concat(lower(author_name), title, length(title)")).reverse_order end end @@ -277,10 +289,10 @@ class RelationTest < ActiveRecord::TestCase def test_reverse_order_with_nulls_first_or_last assert_raises(ActiveRecord::IrreversibleOrderError) do - Topic.order("title NULLS FIRST").reverse_order + Topic.order(Arel.sql("title NULLS FIRST")).reverse_order end assert_raises(ActiveRecord::IrreversibleOrderError) do - Topic.order("title nulls last").reverse_order + Topic.order(Arel.sql("title nulls last")).reverse_order end end @@ -373,29 +385,29 @@ class RelationTest < ActiveRecord::TestCase def test_finding_with_cross_table_order_and_limit tags = Tag.includes(:taggings). - order("tags.name asc", "taggings.taggable_id asc", "REPLACE('abc', taggings.taggable_type, taggings.taggable_type)"). + order("tags.name asc", "taggings.taggable_id asc", Arel.sql("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)")). limit(1).to_a assert_equal 1, tags.length end def test_finding_with_complex_order_and_limit - tags = Tag.includes(:taggings).references(:taggings).order("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)").limit(1).to_a + tags = Tag.includes(:taggings).references(:taggings).order(Arel.sql("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)")).limit(1).to_a assert_equal 1, tags.length end def test_finding_with_complex_order - tags = Tag.includes(:taggings).references(:taggings).order("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)").to_a + tags = Tag.includes(:taggings).references(:taggings).order(Arel.sql("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)")).to_a assert_equal 3, tags.length end def test_finding_with_sanitized_order - query = Tag.order(["field(id, ?)", [1, 3, 2]]).to_sql + query = Tag.order([Arel.sql("field(id, ?)"), [1, 3, 2]]).to_sql assert_match(/field\(id, 1,3,2\)/, query) - query = Tag.order(["field(id, ?)", []]).to_sql + query = Tag.order([Arel.sql("field(id, ?)"), []]).to_sql assert_match(/field\(id, NULL\)/, query) - query = Tag.order(["field(id, ?)", nil]).to_sql + query = Tag.order([Arel.sql("field(id, ?)"), nil]).to_sql assert_match(/field\(id, NULL\)/, query) end @@ -927,13 +939,13 @@ class RelationTest < ActiveRecord::TestCase assert_equal 11, posts.count(:all) assert_equal 11, posts.count(:id) - assert_equal 1, posts.where("comments_count > 1").count - assert_equal 9, posts.where(comments_count: 0).count + assert_equal 3, posts.where("comments_count > 1").count + assert_equal 6, posts.where(comments_count: 0).count end def test_count_with_block posts = Post.all - assert_equal 10, posts.count { |p| p.comments_count.even? } + assert_equal 8, posts.count { |p| p.comments_count.even? } end def test_count_on_association_relation @@ -950,10 +962,10 @@ class RelationTest < ActiveRecord::TestCase def test_count_with_distinct posts = Post.all - assert_equal 3, posts.distinct(true).count(:comments_count) + assert_equal 4, posts.distinct(true).count(:comments_count) assert_equal 11, posts.distinct(false).count(:comments_count) - assert_equal 3, posts.distinct(true).select(:comments_count).count + assert_equal 4, posts.distinct(true).select(:comments_count).count assert_equal 11, posts.distinct(false).select(:comments_count).count end @@ -992,7 +1004,7 @@ class RelationTest < ActiveRecord::TestCase best_posts = posts.where(comments_count: 0) best_posts.load # force load - assert_no_queries { assert_equal 9, best_posts.size } + assert_no_queries { assert_equal 6, best_posts.size } end def test_size_with_limit @@ -1003,7 +1015,7 @@ class RelationTest < ActiveRecord::TestCase best_posts = posts.where(comments_count: 0) best_posts.load # force load - assert_no_queries { assert_equal 9, best_posts.size } + assert_no_queries { assert_equal 6, best_posts.size } end def test_size_with_zero_limit @@ -1026,7 +1038,7 @@ class RelationTest < ActiveRecord::TestCase def test_count_complex_chained_relations posts = Post.select("comments_count").where("id is not null").group("author_id").where("comments_count > 0") - expected = { 1 => 2 } + expected = { 1 => 4, 2 => 1 } assert_equal expected, posts.count end @@ -1177,6 +1189,15 @@ class RelationTest < ActiveRecord::TestCase assert_equal "hen", hen.name end + def test_create_with_polymorphic_association + author = authors(:david) + post = posts(:welcome) + comment = Comment.where(post: post, author: author).create!(body: "hello") + + assert_equal author, comment.author + assert_equal post, comment.post + end + def test_first_or_create parrot = Bird.where(color: "green").first_or_create(name: "parrot") assert_kind_of Bird, parrot @@ -1567,7 +1588,7 @@ class RelationTest < ActiveRecord::TestCase scope = Post.order("comments.body") assert_equal ["comments"], scope.references_values - scope = Post.order("#{Comment.quoted_table_name}.#{Comment.quoted_primary_key}") + scope = Post.order(Arel.sql("#{Comment.quoted_table_name}.#{Comment.quoted_primary_key}")) if current_adapter?(:OracleAdapter) assert_equal ["COMMENTS"], scope.references_values else @@ -1584,7 +1605,7 @@ class RelationTest < ActiveRecord::TestCase scope = Post.order("comments.body asc") assert_equal ["comments"], scope.references_values - scope = Post.order("foo(comments.body)") + scope = Post.order(Arel.sql("foo(comments.body)")) assert_equal [], scope.references_values end @@ -1592,7 +1613,7 @@ class RelationTest < ActiveRecord::TestCase scope = Post.reorder("comments.body") assert_equal %w(comments), scope.references_values - scope = Post.reorder("#{Comment.quoted_table_name}.#{Comment.quoted_primary_key}") + scope = Post.reorder(Arel.sql("#{Comment.quoted_table_name}.#{Comment.quoted_primary_key}")) if current_adapter?(:OracleAdapter) assert_equal ["COMMENTS"], scope.references_values else @@ -1609,7 +1630,7 @@ class RelationTest < ActiveRecord::TestCase scope = Post.reorder("comments.body asc") assert_equal %w(comments), scope.references_values - scope = Post.reorder("foo(comments.body)") + scope = Post.reorder(Arel.sql("foo(comments.body)")) assert_equal [], scope.references_values end @@ -1781,7 +1802,7 @@ class RelationTest < ActiveRecord::TestCase end test "arel_attribute respects a custom table" do - assert_equal [posts(:welcome)], custom_post_relation.ranked_by_comments.limit_by(1).to_a + assert_equal [posts(:sti_comments)], custom_post_relation.ranked_by_comments.limit_by(1).to_a end test "alias_tracker respects a custom table" do diff --git a/activerecord/test/cases/schema_dumper_test.rb b/activerecord/test/cases/schema_dumper_test.rb index ac5092f1c1..a612ce9bb2 100644 --- a/activerecord/test/cases/schema_dumper_test.rb +++ b/activerecord/test/cases/schema_dumper_test.rb @@ -177,14 +177,14 @@ class SchemaDumperTest < ActiveRecord::TestCase def test_schema_dumps_index_columns_in_right_order index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*company_index/).first.strip - if current_adapter?(:PostgreSQLAdapter, :SQLite3Adapter) - assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index", order: { rating: :desc }', index_definition - elsif current_adapter?(:Mysql2Adapter) + if current_adapter?(:Mysql2Adapter) if ActiveRecord::Base.connection.supports_index_sort_order? assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index", length: { type: 10 }, order: { rating: :desc }', index_definition else assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index", length: { type: 10 }', index_definition end + elsif ActiveRecord::Base.connection.supports_index_sort_order? + assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index", order: { rating: :desc }', index_definition else assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index"', index_definition end @@ -199,6 +199,24 @@ class SchemaDumperTest < ActiveRecord::TestCase end end + def test_schema_dumps_index_sort_order + index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*_name_and_rating/).first.strip + if ActiveRecord::Base.connection.supports_index_sort_order? + assert_equal 't.index ["name", "rating"], name: "index_companies_on_name_and_rating", order: :desc', index_definition + else + assert_equal 't.index ["name", "rating"], name: "index_companies_on_name_and_rating"', index_definition + end + end + + def test_schema_dumps_index_length + index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*_name_and_description/).first.strip + if current_adapter?(:Mysql2Adapter) + assert_equal 't.index ["name", "description"], name: "index_companies_on_name_and_description", length: 10', index_definition + else + assert_equal 't.index ["name", "description"], name: "index_companies_on_name_and_description"', index_definition + end + end + def test_schema_dump_should_honor_nonstandard_primary_keys output = standard_dump match = output.match(%r{create_table "movies"(.*)do}) diff --git a/activerecord/test/cases/scoping/default_scoping_test.rb b/activerecord/test/cases/scoping/default_scoping_test.rb index 716ca29eda..fdfeabaa3b 100644 --- a/activerecord/test/cases/scoping/default_scoping_test.rb +++ b/activerecord/test/cases/scoping/default_scoping_test.rb @@ -120,49 +120,49 @@ class DefaultScopingTest < ActiveRecord::TestCase def test_unscope_with_where_attributes expected = Developer.order("salary DESC").collect(&:name) received = DeveloperOrderedBySalary.where(name: "David").unscope(where: :name).collect(&:name) - assert_equal expected, received + assert_equal expected.sort, received.sort expected_2 = Developer.order("salary DESC").collect(&:name) received_2 = DeveloperOrderedBySalary.select("id").where("name" => "Jamis").unscope({ where: :name }, :select).collect(&:name) - assert_equal expected_2, received_2 + assert_equal expected_2.sort, received_2.sort expected_3 = Developer.order("salary DESC").collect(&:name) received_3 = DeveloperOrderedBySalary.select("id").where("name" => "Jamis").unscope(:select, :where).collect(&:name) - assert_equal expected_3, received_3 + assert_equal expected_3.sort, received_3.sort expected_4 = Developer.order("salary DESC").collect(&:name) received_4 = DeveloperOrderedBySalary.where.not("name" => "Jamis").unscope(where: :name).collect(&:name) - assert_equal expected_4, received_4 + assert_equal expected_4.sort, received_4.sort expected_5 = Developer.order("salary DESC").collect(&:name) received_5 = DeveloperOrderedBySalary.where.not("name" => ["Jamis", "David"]).unscope(where: :name).collect(&:name) - assert_equal expected_5, received_5 + assert_equal expected_5.sort, received_5.sort expected_6 = Developer.order("salary DESC").collect(&:name) received_6 = DeveloperOrderedBySalary.where(Developer.arel_table["name"].eq("David")).unscope(where: :name).collect(&:name) - assert_equal expected_6, received_6 + assert_equal expected_6.sort, received_6.sort expected_7 = Developer.order("salary DESC").collect(&:name) received_7 = DeveloperOrderedBySalary.where(Developer.arel_table[:name].eq("David")).unscope(where: :name).collect(&:name) - assert_equal expected_7, received_7 + assert_equal expected_7.sort, received_7.sort end def test_unscope_comparison_where_clauses # unscoped for WHERE (`developers`.`id` <= 2) expected = Developer.order("salary DESC").collect(&:name) received = DeveloperOrderedBySalary.where(id: -Float::INFINITY..2).unscope(where: :id).collect { |dev| dev.name } - assert_equal expected, received + assert_equal expected.sort, received.sort # unscoped for WHERE (`developers`.`id` < 2) expected = Developer.order("salary DESC").collect(&:name) received = DeveloperOrderedBySalary.where(id: -Float::INFINITY...2).unscope(where: :id).collect { |dev| dev.name } - assert_equal expected, received + assert_equal expected.sort, received.sort end def test_unscope_multiple_where_clauses expected = Developer.order("salary DESC").collect(&:name) received = DeveloperOrderedBySalary.where(name: "Jamis").where(id: 1).unscope(where: [:name, :id]).collect(&:name) - assert_equal expected, received + assert_equal expected.sort, received.sort end def test_unscope_string_where_clauses_involved @@ -172,23 +172,23 @@ class DefaultScopingTest < ActiveRecord::TestCase dev_ordered_relation = DeveloperOrderedBySalary.where(name: "Jamis").where("created_at > ?", 1.year.ago) received = dev_ordered_relation.unscope(where: [:name]).collect(&:name) - assert_equal expected, received + assert_equal expected.sort, received.sort end def test_unscope_with_grouping_attributes expected = Developer.order("salary DESC").collect(&:name) received = DeveloperOrderedBySalary.group(:name).unscope(:group).collect(&:name) - assert_equal expected, received + assert_equal expected.sort, received.sort expected_2 = Developer.order("salary DESC").collect(&:name) received_2 = DeveloperOrderedBySalary.group("name").unscope(:group).collect(&:name) - assert_equal expected_2, received_2 + assert_equal expected_2.sort, received_2.sort end def test_unscope_with_limit_in_query expected = Developer.order("salary DESC").collect(&:name) received = DeveloperOrderedBySalary.limit(1).unscope(:limit).collect(&:name) - assert_equal expected, received + assert_equal expected.sort, received.sort end def test_order_to_unscope_reordering @@ -472,7 +472,7 @@ class DefaultScopingTest < ActiveRecord::TestCase test "a scope can remove the condition from the default scope" do scope = DeveloperCalledJamis.david2 assert_equal 1, scope.where_clause.ast.children.length - assert_equal Developer.where(name: "David"), scope + assert_equal Developer.where(name: "David").map(&:id), scope.map(&:id) end def test_with_abstract_class_where_clause_should_not_be_duplicated diff --git a/activerecord/test/cases/scoping/named_scoping_test.rb b/activerecord/test/cases/scoping/named_scoping_test.rb index b0431a4e34..17d3f27bb1 100644 --- a/activerecord/test/cases/scoping/named_scoping_test.rb +++ b/activerecord/test/cases/scoping/named_scoping_test.rb @@ -151,6 +151,22 @@ class NamedScopingTest < ActiveRecord::TestCase assert_equal "The scope body needs to be callable.", e.message end + def test_scopes_name_is_relation_method + conflicts = [ + :records, + :to_ary, + :to_sql, + :explain + ] + + conflicts.each do |name| + e = assert_raises ArgumentError do + Class.new(Post).class_eval { scope name, -> { where(approved: true) } } + end + assert_match(/You tried to define a scope named \"#{name}\" on the model/, e.message) + end + end + def test_active_records_have_scope_named__all__ assert !Topic.all.empty? diff --git a/activerecord/test/cases/scoping/relation_scoping_test.rb b/activerecord/test/cases/scoping/relation_scoping_test.rb index f3b84d88c2..116f8e83aa 100644 --- a/activerecord/test/cases/scoping/relation_scoping_test.rb +++ b/activerecord/test/cases/scoping/relation_scoping_test.rb @@ -240,6 +240,20 @@ class RelationScopingTest < ActiveRecord::TestCase assert_nil SpecialComment.current_scope end + def test_scoping_respects_current_class + Comment.unscoped do + assert_equal "a comment...", Comment.all.what_are_you + assert_equal "a special comment...", SpecialComment.all.what_are_you + end + end + + def test_scoping_respects_sti_constraint + Comment.unscoped do + assert_equal comments(:greetings), Comment.find(1) + assert_raises(ActiveRecord::RecordNotFound) { SpecialComment.find(1) } + end + end + def test_circular_joins_with_scoping_does_not_crash posts = Post.joins(comments: :post).scoping do Post.first(10) diff --git a/activerecord/test/cases/tasks/database_tasks_test.rb b/activerecord/test/cases/tasks/database_tasks_test.rb index 1495d2ab89..5a094ead42 100644 --- a/activerecord/test/cases/tasks/database_tasks_test.rb +++ b/activerecord/test/cases/tasks/database_tasks_test.rb @@ -357,8 +357,15 @@ module ActiveRecord ActiveRecord::Migration.expects(:verbose=).with(ActiveRecord::Migration.verbose) ActiveRecord::Tasks::DatabaseTasks.migrate + ENV["VERBOSE"] = "" + ENV["VERSION"] = "" + ActiveRecord::Migrator.expects(:migrate).with("custom/path", nil) + ActiveRecord::Migration.expects(:verbose=).with(true) + ActiveRecord::Migration.expects(:verbose=).with(ActiveRecord::Migration.verbose) + ActiveRecord::Tasks::DatabaseTasks.migrate + ENV["VERBOSE"] = "yes" - ENV["VERSION"] = "unknown" + ENV["VERSION"] = "0" ActiveRecord::Migrator.expects(:migrate).with("custom/path", 0) ActiveRecord::Migration.expects(:verbose=).with(true) ActiveRecord::Migration.expects(:verbose=).with(ActiveRecord::Migration.verbose) @@ -367,15 +374,47 @@ module ActiveRecord ENV["VERBOSE"], ENV["VERSION"] = verbose, version end - def test_migrate_raise_error_on_empty_version + def test_migrate_raise_error_on_invalid_version_format version = ENV["VERSION"] - ENV["VERSION"] = "" + + ENV["VERSION"] = "unknown" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "0.1.11" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1.1.11" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "0 " + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1." + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1_" e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } - assert_equal "Empty VERSION provided", e.message + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1_name" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) ensure ENV["VERSION"] = version end + def test_migrate_raise_error_on_failed_check_target_version + ActiveRecord::Tasks::DatabaseTasks.stubs(:check_target_version).raises("foo") + + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_equal "foo", e.message + end + def test_migrate_clears_schema_cache_afterward ActiveRecord::Base.expects(:clear_cache!) ActiveRecord::Tasks::DatabaseTasks.migrate @@ -444,6 +483,108 @@ module ActiveRecord end end + class DatabaseTaskTargetVersionTest < ActiveRecord::TestCase + def test_target_version_returns_nil_if_version_does_not_exist + version = ENV.delete("VERSION") + assert_nil ActiveRecord::Tasks::DatabaseTasks.target_version + ensure + ENV["VERSION"] = version + end + + def test_target_version_returns_nil_if_version_is_empty + version = ENV["VERSION"] + + ENV["VERSION"] = "" + assert_nil ActiveRecord::Tasks::DatabaseTasks.target_version + ensure + ENV["VERSION"] = version + end + + def test_target_version_returns_converted_to_integer_env_version_if_version_exists + version = ENV["VERSION"] + + ENV["VERSION"] = "0" + assert_equal ENV["VERSION"].to_i, ActiveRecord::Tasks::DatabaseTasks.target_version + + ENV["VERSION"] = "42" + assert_equal ENV["VERSION"].to_i, ActiveRecord::Tasks::DatabaseTasks.target_version + + ENV["VERSION"] = "042" + assert_equal ENV["VERSION"].to_i, ActiveRecord::Tasks::DatabaseTasks.target_version + ensure + ENV["VERSION"] = version + end + end + + class DatabaseTaskCheckTargetVersionTest < ActiveRecord::TestCase + def test_check_target_version_does_not_raise_error_on_empty_version + version = ENV["VERSION"] + ENV["VERSION"] = "" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + ensure + ENV["VERSION"] = version + end + + def test_check_target_version_does_not_raise_error_if_version_is_not_setted + version = ENV.delete("VERSION") + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + ensure + ENV["VERSION"] = version + end + + def test_check_target_version_raises_error_on_invalid_version_format + version = ENV["VERSION"] + + ENV["VERSION"] = "unknown" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "0.1.11" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1.1.11" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "0 " + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1." + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1_" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1_name" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + ensure + ENV["VERSION"] = version + end + + def test_check_target_version_does_not_raise_error_on_valid_version_format + version = ENV["VERSION"] + + ENV["VERSION"] = "0" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + + ENV["VERSION"] = "1" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + + ENV["VERSION"] = "001" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + + ENV["VERSION"] = "001_name.rb" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + ensure + ENV["VERSION"] = version + end + end + class DatabaseTasksStructureDumpTest < ActiveRecord::TestCase include DatabaseTasksSetupper diff --git a/activerecord/test/cases/tasks/mysql_rake_test.rb b/activerecord/test/cases/tasks/mysql_rake_test.rb index 98fe24baa0..047153e7cc 100644 --- a/activerecord/test/cases/tasks/mysql_rake_test.rb +++ b/activerecord/test/cases/tasks/mysql_rake_test.rb @@ -75,7 +75,7 @@ if current_adapter?(:Mysql2Adapter) end end - class MysqlDBCreateAsRootTest < ActiveRecord::TestCase + class MysqlDBCreateWithInvalidPermissionsTest < ActiveRecord::TestCase def setup @connection = stub("Connection", create_database: true) @error = Mysql2::Error.new("Invalid permissions") @@ -86,13 +86,8 @@ if current_adapter?(:Mysql2Adapter) "password" => "wossname" } - $stdin.stubs(:gets).returns("secret\n") - $stdout.stubs(:print).returns(nil) - @error.stubs(:errno).returns(1045) ActiveRecord::Base.stubs(:connection).returns(@connection) - ActiveRecord::Base.stubs(:establish_connection). - raises(@error). - then.returns(true) + ActiveRecord::Base.stubs(:establish_connection).raises(@error) $stdout, @original_stdout = StringIO.new, $stdout $stderr, @original_stderr = StringIO.new, $stderr @@ -102,75 +97,11 @@ if current_adapter?(:Mysql2Adapter) $stdout, $stderr = @original_stdout, @original_stderr end - def test_root_password_is_requested - assert_permissions_granted_for("pat") - $stdin.expects(:gets).returns("secret\n") - - ActiveRecord::Tasks::DatabaseTasks.create @configuration - end - - def test_connection_established_as_root - assert_permissions_granted_for("pat") - ActiveRecord::Base.expects(:establish_connection).with( - "adapter" => "mysql2", - "database" => nil, - "username" => "root", - "password" => "secret" - ) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration - end - - def test_database_created_by_root - assert_permissions_granted_for("pat") - @connection.expects(:create_database). - with("my-app-db", {}) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration - end - - def test_grant_privileges_for_normal_user - assert_permissions_granted_for("pat") - ActiveRecord::Tasks::DatabaseTasks.create @configuration - end - - def test_do_not_grant_privileges_for_root_user - @configuration["username"] = "root" - @configuration["password"] = "" - ActiveRecord::Tasks::DatabaseTasks.create @configuration - end - - def test_connection_established_as_normal_user - assert_permissions_granted_for("pat") - ActiveRecord::Base.expects(:establish_connection).returns do - ActiveRecord::Base.expects(:establish_connection).with( - "adapter" => "mysql2", - "database" => "my-app-db", - "username" => "pat", - "password" => "secret" - ) - - raise @error + def test_raises_error + assert_raises(Mysql2::Error) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration end - - ActiveRecord::Tasks::DatabaseTasks.create @configuration end - - def test_sends_output_to_stderr_when_other_errors - @error.stubs(:errno).returns(42) - - $stderr.expects(:puts).at_least_once.returns(nil) - - ActiveRecord::Tasks::DatabaseTasks.create @configuration - end - - private - - def assert_permissions_granted_for(db_user) - db_name = @configuration["database"] - db_password = @configuration["password"] - @connection.expects(:execute).with("GRANT ALL PRIVILEGES ON `#{db_name}`.* TO '#{db_user}'@'localhost' IDENTIFIED BY '#{db_password}' WITH GRANT OPTION;") - end end class MySQLDBDropTest < ActiveRecord::TestCase diff --git a/activerecord/test/cases/transaction_isolation_test.rb b/activerecord/test/cases/transaction_isolation_test.rb index b1ebccdcc3..eaafd13360 100644 --- a/activerecord/test/cases/transaction_isolation_test.rb +++ b/activerecord/test/cases/transaction_isolation_test.rb @@ -15,9 +15,7 @@ unless ActiveRecord::Base.connection.supports_transaction_isolation? end end end -end - -if ActiveRecord::Base.connection.supports_transaction_isolation? +else class TransactionIsolationTest < ActiveRecord::TestCase self.use_transactional_tests = false diff --git a/activerecord/test/cases/transactions_test.rb b/activerecord/test/cases/transactions_test.rb index 7fd125ab74..5c8ae4d3cb 100644 --- a/activerecord/test/cases/transactions_test.rb +++ b/activerecord/test/cases/transactions_test.rb @@ -954,27 +954,25 @@ class TransactionsWithTransactionalFixturesTest < ActiveRecord::TestCase end end if Topic.connection.supports_savepoints? -if current_adapter?(:PostgreSQLAdapter) +if ActiveRecord::Base.connection.supports_transaction_isolation? class ConcurrentTransactionTest < TransactionTest # This will cause transactions to overlap and fail unless they are performed on # separate database connections. - unless in_memory_db? - def test_transaction_per_thread - threads = 3.times.map do - Thread.new do - Topic.transaction do - topic = Topic.find(1) - topic.approved = !topic.approved? - assert topic.save! - topic.approved = !topic.approved? - assert topic.save! - end - Topic.connection.close + def test_transaction_per_thread + threads = 3.times.map do + Thread.new do + Topic.transaction do + topic = Topic.find(1) + topic.approved = !topic.approved? + assert topic.save! + topic.approved = !topic.approved? + assert topic.save! end + Topic.connection.close end - - threads.each(&:join) end + + threads.each(&:join) end # Test for dirty reads among simultaneous transactions. diff --git a/activerecord/test/cases/unsafe_raw_sql_test.rb b/activerecord/test/cases/unsafe_raw_sql_test.rb new file mode 100644 index 0000000000..72d4997d0b --- /dev/null +++ b/activerecord/test/cases/unsafe_raw_sql_test.rb @@ -0,0 +1,299 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/comment" + +class UnsafeRawSqlTest < ActiveRecord::TestCase + fixtures :posts, :comments + + test "order: allows string column name" do + ids_expected = Post.order(Arel.sql("title")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order("title").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order("title").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows symbol column name" do + ids_expected = Post.order(Arel.sql("title")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(:title).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(:title).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows downcase symbol direction" do + ids_expected = Post.order(Arel.sql("title") => Arel.sql("asc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(title: :asc).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(title: :asc).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows upcase symbol direction" do + ids_expected = Post.order(Arel.sql("title") => Arel.sql("ASC")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(title: :ASC).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(title: :ASC).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows string direction" do + ids_expected = Post.order(Arel.sql("title") => Arel.sql("asc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(title: "asc").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(title: "asc").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows multiple columns" do + ids_expected = Post.order(Arel.sql("author_id"), Arel.sql("title")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(:author_id, :title).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(:author_id, :title).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows mixed" do + ids_expected = Post.order(Arel.sql("author_id"), Arel.sql("title") => Arel.sql("asc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(:author_id, title: :asc).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(:author_id, title: :asc).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows table and column name" do + ids_expected = Post.order(Arel.sql("title")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order("posts.title").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order("posts.title").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows column name and direction in string" do + ids_expected = Post.order(Arel.sql("title desc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order("title desc").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order("title desc").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows table name, column name and direction in string" do + ids_expected = Post.order(Arel.sql("title desc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order("posts.title desc").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order("posts.title desc").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: disallows invalid column name" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.order("len(title) asc").pluck(:id) + end + end + end + + test "order: disallows invalid direction" do + with_unsafe_raw_sql_disabled do + assert_raises(ArgumentError) do + Post.order(title: :foo).pluck(:id) + end + end + end + + test "order: disallows invalid column with direction" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.order("len(title)" => :asc).pluck(:id) + end + end + end + + test "order: always allows Arel" do + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(Arel.sql("length(title)")).pluck(:title) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(Arel.sql("length(title)")).pluck(:title) } + + assert_equal ids_depr, ids_disabled + end + + test "order: allows Arel.sql with binds" do + ids_expected = Post.order(Arel.sql("REPLACE(title, 'misc', 'zzzz'), id")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order([Arel.sql("REPLACE(title, ?, ?), id"), "misc", "zzzz"]).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order([Arel.sql("REPLACE(title, ?, ?), id"), "misc", "zzzz"]).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: disallows invalid bind statement" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.order(["REPLACE(title, ?, ?), id", "misc", "zzzz"]).pluck(:id) + end + end + end + + test "order: disallows invalid Array arguments" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.order(["author_id", "length(title)"]).pluck(:id) + end + end + end + + test "order: allows valid Array arguments" do + ids_expected = Post.order(Arel.sql("author_id, length(title)")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(["author_id", Arel.sql("length(title)")]).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(["author_id", Arel.sql("length(title)")]).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: logs deprecation warning for unrecognized column" do + with_unsafe_raw_sql_deprecated do + assert_deprecated(/Dangerous query method/) do + Post.order("length(title)") + end + end + end + + test "pluck: allows string column name" do + titles_expected = Post.pluck(Arel.sql("title")) + + titles_depr = with_unsafe_raw_sql_deprecated { Post.pluck("title") } + titles_disabled = with_unsafe_raw_sql_disabled { Post.pluck("title") } + + assert_equal titles_expected, titles_depr + assert_equal titles_expected, titles_disabled + end + + test "pluck: allows symbol column name" do + titles_expected = Post.pluck(Arel.sql("title")) + + titles_depr = with_unsafe_raw_sql_deprecated { Post.pluck(:title) } + titles_disabled = with_unsafe_raw_sql_disabled { Post.pluck(:title) } + + assert_equal titles_expected, titles_depr + assert_equal titles_expected, titles_disabled + end + + test "pluck: allows multiple column names" do + values_expected = Post.pluck(Arel.sql("title"), Arel.sql("id")) + + values_depr = with_unsafe_raw_sql_deprecated { Post.pluck(:title, :id) } + values_disabled = with_unsafe_raw_sql_disabled { Post.pluck(:title, :id) } + + assert_equal values_expected, values_depr + assert_equal values_expected, values_disabled + end + + test "pluck: allows column names with includes" do + values_expected = Post.includes(:comments).pluck(Arel.sql("title"), Arel.sql("id")) + + values_depr = with_unsafe_raw_sql_deprecated { Post.includes(:comments).pluck(:title, :id) } + values_disabled = with_unsafe_raw_sql_disabled { Post.includes(:comments).pluck(:title, :id) } + + assert_equal values_expected, values_depr + assert_equal values_expected, values_disabled + end + + test "pluck: allows auto-generated attributes" do + values_expected = Post.pluck(Arel.sql("tags_count")) + + values_depr = with_unsafe_raw_sql_deprecated { Post.pluck(:tags_count) } + values_disabled = with_unsafe_raw_sql_disabled { Post.pluck(:tags_count) } + + assert_equal values_expected, values_depr + assert_equal values_expected, values_disabled + end + + test "pluck: allows table and column names" do + titles_expected = Post.pluck(Arel.sql("title")) + + titles_depr = with_unsafe_raw_sql_deprecated { Post.pluck("posts.title") } + titles_disabled = with_unsafe_raw_sql_disabled { Post.pluck("posts.title") } + + assert_equal titles_expected, titles_depr + assert_equal titles_expected, titles_disabled + end + + test "pluck: disallows invalid column name" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.pluck("length(title)") + end + end + end + + test "pluck: disallows invalid column name amongst valid names" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.pluck(:title, "length(title)") + end + end + end + + test "pluck: disallows invalid column names with includes" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.includes(:comments).pluck(:title, "length(title)") + end + end + end + + test "pluck: always allows Arel" do + values_depr = with_unsafe_raw_sql_deprecated { Post.includes(:comments).pluck(:title, Arel.sql("length(title)")) } + values_disabled = with_unsafe_raw_sql_disabled { Post.includes(:comments).pluck(:title, Arel.sql("length(title)")) } + + assert_equal values_depr, values_disabled + end + + test "pluck: logs deprecation warning" do + with_unsafe_raw_sql_deprecated do + assert_deprecated(/Dangerous query method/) do + Post.includes(:comments).pluck(:title, "length(title)") + end + end + end + + def with_unsafe_raw_sql_disabled(&blk) + with_config(:disabled, &blk) + end + + def with_unsafe_raw_sql_deprecated(&blk) + with_config(:deprecated, &blk) + end + + def with_config(new_value, &blk) + old_value = ActiveRecord::Base.allow_unsafe_raw_sql + ActiveRecord::Base.allow_unsafe_raw_sql = new_value + blk.call + ensure + ActiveRecord::Base.allow_unsafe_raw_sql = old_value + end +end diff --git a/activerecord/test/fixtures/other_posts.yml b/activerecord/test/fixtures/other_posts.yml index 39ff763547..3e11a33802 100644 --- a/activerecord/test/fixtures/other_posts.yml +++ b/activerecord/test/fixtures/other_posts.yml @@ -5,3 +5,4 @@ second_welcome: author_id: 1 title: Welcome to the another weblog body: It's really nice today + comments_count: 1 diff --git a/activerecord/test/fixtures/posts.yml b/activerecord/test/fixtures/posts.yml index 86d46f753a..8d7e1e0ae7 100644 --- a/activerecord/test/fixtures/posts.yml +++ b/activerecord/test/fixtures/posts.yml @@ -28,6 +28,7 @@ sti_comments: author_id: 1 title: sti comments body: hello + comments_count: 5 type: Post sti_post_and_comments: @@ -35,6 +36,7 @@ sti_post_and_comments: author_id: 1 title: sti me body: hello + comments_count: 2 type: StiPost sti_habtm: @@ -50,6 +52,8 @@ eager_other: title: eager loading with OR'd conditions body: hello type: Post + comments_count: 1 + tags_count: 3 misc_by_bob: id: 8 @@ -57,6 +61,7 @@ misc_by_bob: title: misc post by bob body: hello type: Post + tags_count: 1 misc_by_mary: id: 9 @@ -64,6 +69,7 @@ misc_by_mary: title: misc post by mary body: hello type: Post + tags_count: 1 other_by_bob: id: 10 @@ -71,6 +77,7 @@ other_by_bob: title: other post by bob body: hello type: Post + tags_count: 1 other_by_mary: id: 11 @@ -78,3 +85,4 @@ other_by_mary: title: other post by mary body: hello type: Post + tags_count: 1 diff --git a/activerecord/test/models/comment.rb b/activerecord/test/models/comment.rb index 740aa593ac..5ab433f2d9 100644 --- a/activerecord/test/models/comment.rb +++ b/activerecord/test/models/comment.rb @@ -60,6 +60,10 @@ end class SpecialComment < Comment default_scope { where(deleted_at: nil) } + + def self.what_are_you + "a special comment..." + end end class SubSpecialComment < SpecialComment diff --git a/activerecord/test/models/face.rb b/activerecord/test/models/face.rb index 796aaa4dc9..948435136d 100644 --- a/activerecord/test/models/face.rb +++ b/activerecord/test/models/face.rb @@ -8,4 +8,8 @@ class Face < ActiveRecord::Base # These is a "broken" inverse_of for the purposes of testing belongs_to :horrible_man, class_name: "Man", inverse_of: :horrible_face belongs_to :horrible_polymorphic_man, polymorphic: true, inverse_of: :horrible_polymorphic_face + + validate do + man + end end diff --git a/activerecord/test/models/post.rb b/activerecord/test/models/post.rb index 7f064bf3dd..780a2c17f5 100644 --- a/activerecord/test/models/post.rb +++ b/activerecord/test/models/post.rb @@ -319,5 +319,9 @@ class FakeKlass def arel_attribute(name, table) table[name] end + + def enforce_raw_sql_whitelist(*args) + # noop + end end end diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index 8f872c38ba..bf66846840 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -191,6 +191,7 @@ ActiveRecord::Schema.define do t.string :resource_id t.string :resource_type t.integer :developer_id + t.datetime :updated_at t.datetime :deleted_at t.integer :comments end @@ -204,6 +205,8 @@ ActiveRecord::Schema.define do t.bigint :rating, default: 1 t.integer :account_id t.string :description, default: "" + t.index [:name, :rating], order: :desc + t.index [:name, :description], length: 10 t.index [:firm_id, :type, :rating], name: "company_index", length: { type: 10 }, order: { rating: :desc } t.index [:firm_id, :type], name: "company_partial_index", where: "(rating > 10)" t.index :name, name: "company_name_index", using: :btree |