diff options
Diffstat (limited to 'activerecord/lib/active_record')
157 files changed, 6032 insertions, 3487 deletions
diff --git a/activerecord/lib/active_record/aggregations.rb b/activerecord/lib/active_record/aggregations.rb index 27a641f05b..3250e29b82 100644 --- a/activerecord/lib/active_record/aggregations.rb +++ b/activerecord/lib/active_record/aggregations.rb @@ -3,8 +3,6 @@ module ActiveRecord # See ActiveRecord::Aggregations::ClassMethods for documentation module Aggregations - extend ActiveSupport::Concern - def initialize_dup(*) # :nodoc: @aggregation_cache = {} super @@ -225,6 +223,10 @@ module ActiveRecord def composed_of(part_id, options = {}) options.assert_valid_keys(:class_name, :mapping, :allow_nil, :constructor, :converter) + unless self < Aggregations + include Aggregations + end + name = part_id.id2name class_name = options[:class_name] || name.camelize mapping = options[:mapping] || [ name, name ] diff --git a/activerecord/lib/active_record/association_relation.rb b/activerecord/lib/active_record/association_relation.rb index 403667fb70..4c538ef2bd 100644 --- a/activerecord/lib/active_record/association_relation.rb +++ b/activerecord/lib/active_record/association_relation.rb @@ -31,9 +31,9 @@ module ActiveRecord private def exec_queries - super do |r| - @association.set_inverse_instance r - yield r if block_given? + super do |record| + @association.set_inverse_instance_from_queries(record) + yield record if block_given? end end end diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index b1cad0d0a4..64c20adc87 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -241,7 +241,7 @@ module ActiveRecord association end - def association_cached?(name) # :nodoc + def association_cached?(name) # :nodoc: @association_cache.key?(name) end @@ -292,13 +292,13 @@ module ActiveRecord # # The project class now has the following methods (and more) to ease the traversal and # manipulation of its relationships: - # * <tt>Project#portfolio, Project#portfolio=(portfolio), Project#portfolio.nil?</tt> - # * <tt>Project#project_manager, Project#project_manager=(project_manager), Project#project_manager.nil?,</tt> - # * <tt>Project#milestones.empty?, Project#milestones.size, Project#milestones, Project#milestones<<(milestone),</tt> - # <tt>Project#milestones.delete(milestone), Project#milestones.destroy(milestone), Project#milestones.find(milestone_id),</tt> - # <tt>Project#milestones.build, Project#milestones.create</tt> - # * <tt>Project#categories.empty?, Project#categories.size, Project#categories, Project#categories<<(category1),</tt> - # <tt>Project#categories.delete(category1), Project#categories.destroy(category1)</tt> + # * <tt>Project#portfolio</tt>, <tt>Project#portfolio=(portfolio)</tt>, <tt>Project#reload_portfolio</tt> + # * <tt>Project#project_manager</tt>, <tt>Project#project_manager=(project_manager)</tt>, <tt>Project#reload_project_manager</tt> + # * <tt>Project#milestones.empty?</tt>, <tt>Project#milestones.size</tt>, <tt>Project#milestones</tt>, <tt>Project#milestones<<(milestone)</tt>, + # <tt>Project#milestones.delete(milestone)</tt>, <tt>Project#milestones.destroy(milestone)</tt>, <tt>Project#milestones.find(milestone_id)</tt>, + # <tt>Project#milestones.build</tt>, <tt>Project#milestones.create</tt> + # * <tt>Project#categories.empty?</tt>, <tt>Project#categories.size</tt>, <tt>Project#categories</tt>, <tt>Project#categories<<(category1)</tt>, + # <tt>Project#categories.delete(category1)</tt>, <tt>Project#categories.destroy(category1)</tt> # # === A word of warning # @@ -703,8 +703,9 @@ module ActiveRecord # #belongs_to associations. # # Extra options on the associations, as defined in the - # <tt>AssociationReflection::INVALID_AUTOMATIC_INVERSE_OPTIONS</tt> constant, will - # also prevent the association's inverse from being found automatically. + # <tt>AssociationReflection::INVALID_AUTOMATIC_INVERSE_OPTIONS</tt> + # constant, or a custom scope, will also prevent the association's inverse + # from being found automatically. # # The automatic guessing of the inverse association uses a heuristic based # on the name of the class, so it may not work for all associations, @@ -1232,9 +1233,9 @@ module ActiveRecord # * <tt>Firm#clients.size</tt> (similar to <tt>Client.count "firm_id = #{id}"</tt>) # * <tt>Firm#clients.find</tt> (similar to <tt>Client.where(firm_id: id).find(id)</tt>) # * <tt>Firm#clients.exists?(name: 'ACME')</tt> (similar to <tt>Client.exists?(name: 'ACME', firm_id: firm.id)</tt>) - # * <tt>Firm#clients.build</tt> (similar to <tt>Client.new("firm_id" => id)</tt>) - # * <tt>Firm#clients.create</tt> (similar to <tt>c = Client.new("firm_id" => id); c.save; c</tt>) - # * <tt>Firm#clients.create!</tt> (similar to <tt>c = Client.new("firm_id" => id); c.save!</tt>) + # * <tt>Firm#clients.build</tt> (similar to <tt>Client.new(firm_id: id)</tt>) + # * <tt>Firm#clients.create</tt> (similar to <tt>c = Client.new(firm_id: id); c.save; c</tt>) + # * <tt>Firm#clients.create!</tt> (similar to <tt>c = Client.new(firm_id: id); c.save!</tt>) # * <tt>Firm#clients.reload</tt> # The declaration can also include an +options+ hash to specialize the behavior of the association. # @@ -1293,8 +1294,9 @@ module ActiveRecord # # * <tt>:destroy</tt> causes all the associated objects to also be destroyed. # * <tt>:delete_all</tt> causes all the associated objects to be deleted directly from the database (so callbacks will not be executed). - # * <tt>:nullify</tt> causes the foreign keys to be set to +NULL+. Callbacks are not executed. - # * <tt>:restrict_with_exception</tt> causes an exception to be raised if there are any associated records. + # * <tt>:nullify</tt> causes the foreign keys to be set to +NULL+. Polymorphic type will also be nullified + # on polymorphic associations. Callbacks are not executed. + # * <tt>:restrict_with_exception</tt> causes an <tt>ActiveRecord::DeleteRestrictionError</tt> exception to be raised if there are any associated records. # * <tt>:restrict_with_error</tt> causes an error to be added to the owner if there are any associated objects. # # If using with the <tt>:through</tt> option, the association on the join model must be @@ -1405,9 +1407,9 @@ module ActiveRecord # An Account class declares <tt>has_one :beneficiary</tt>, which will add: # * <tt>Account#beneficiary</tt> (similar to <tt>Beneficiary.where(account_id: id).first</tt>) # * <tt>Account#beneficiary=(beneficiary)</tt> (similar to <tt>beneficiary.account_id = account.id; beneficiary.save</tt>) - # * <tt>Account#build_beneficiary</tt> (similar to <tt>Beneficiary.new("account_id" => id)</tt>) - # * <tt>Account#create_beneficiary</tt> (similar to <tt>b = Beneficiary.new("account_id" => id); b.save; b</tt>) - # * <tt>Account#create_beneficiary!</tt> (similar to <tt>b = Beneficiary.new("account_id" => id); b.save!; b</tt>) + # * <tt>Account#build_beneficiary</tt> (similar to <tt>Beneficiary.new(account_id: id)</tt>) + # * <tt>Account#create_beneficiary</tt> (similar to <tt>b = Beneficiary.new(account_id: id); b.save; b</tt>) + # * <tt>Account#create_beneficiary!</tt> (similar to <tt>b = Beneficiary.new(account_id: id); b.save!; b</tt>) # * <tt>Account#reload_beneficiary</tt> # # === Scopes @@ -1436,8 +1438,9 @@ module ActiveRecord # # * <tt>:destroy</tt> causes the associated object to also be destroyed # * <tt>:delete</tt> causes the associated object to be deleted directly from the database (so callbacks will not execute) - # * <tt>:nullify</tt> causes the foreign key to be set to +NULL+. Callbacks are not executed. - # * <tt>:restrict_with_exception</tt> causes an exception to be raised if there is an associated record + # * <tt>:nullify</tt> causes the foreign key to be set to +NULL+. Polymorphic type column is also nullified + # on polymorphic associations. Callbacks are not executed. + # * <tt>:restrict_with_exception</tt> causes an <tt>ActiveRecord::DeleteRestrictionError</tt> exception to be raised if there is an associated record # * <tt>:restrict_with_error</tt> causes an error to be added to the owner if there is an associated object # # Note that <tt>:dependent</tt> option is ignored when using <tt>:through</tt> option. @@ -1524,6 +1527,7 @@ module ActiveRecord # Returns the associated object. +nil+ is returned if none is found. # [association=(associate)] # Assigns the associate object, extracts the primary key, and sets it as the foreign key. + # No modification or deletion of existing records takes place. # [build_association(attributes = {})] # Returns a new object of the associated type that has been instantiated # with +attributes+ and linked to this object through a foreign key, but has not yet been saved. @@ -1581,7 +1585,7 @@ module ActiveRecord # association will use "taggable_type" as the default <tt>:foreign_type</tt>. # [:primary_key] # Specify the method that returns the primary key of associated object used for the association. - # By default this is id. + # By default this is +id+. # [:dependent] # If set to <tt>:destroy</tt>, the associated object is destroyed when this object is. If set to # <tt>:delete</tt>, the associated object is deleted *without* calling its destroy method. @@ -1746,8 +1750,8 @@ module ActiveRecord # * <tt>Developer#projects.size</tt> # * <tt>Developer#projects.find(id)</tt> # * <tt>Developer#projects.exists?(...)</tt> - # * <tt>Developer#projects.build</tt> (similar to <tt>Project.new("developer_id" => id)</tt>) - # * <tt>Developer#projects.create</tt> (similar to <tt>c = Project.new("developer_id" => id); c.save; c</tt>) + # * <tt>Developer#projects.build</tt> (similar to <tt>Project.new(developer_id: id)</tt>) + # * <tt>Developer#projects.create</tt> (similar to <tt>c = Project.new(developer_id: id); c.save; c</tt>) # * <tt>Developer#projects.reload</tt> # The declaration may include an +options+ hash to specialize the behavior of the association. # @@ -1761,6 +1765,7 @@ module ActiveRecord # has_and_belongs_to_many :projects, -> { includes(:milestones, :manager) } # has_and_belongs_to_many :categories, ->(post) { # where("default_category = ?", post.default_category) + # } # # === Extensions # diff --git a/activerecord/lib/active_record/associations/alias_tracker.rb b/activerecord/lib/active_record/associations/alias_tracker.rb index 4f3893588e..272eede824 100644 --- a/activerecord/lib/active_record/associations/alias_tracker.rb +++ b/activerecord/lib/active_record/associations/alias_tracker.rb @@ -33,7 +33,7 @@ module ActiveRecord elsif join.is_a?(Arel::Nodes::Join) join.left.name == name ? 1 : 0 elsif join.is_a?(Hash) - join.fetch(name, 0) + join[name] else raise ArgumentError, "joins list should be initialized by list of Arel::Nodes::Join" end diff --git a/activerecord/lib/active_record/associations/association.rb b/activerecord/lib/active_record/associations/association.rb index ca8c7794e0..cf22b850b9 100644 --- a/activerecord/lib/active_record/associations/association.rb +++ b/activerecord/lib/active_record/associations/association.rb @@ -17,9 +17,25 @@ module ActiveRecord # CollectionAssociation # HasManyAssociation + ForeignAssociation # HasManyThroughAssociation + ThroughAssociation + # + # Associations in Active Record are middlemen between the object that + # holds the association, known as the <tt>owner</tt>, and the associated + # result set, known as the <tt>target</tt>. Association metadata is available in + # <tt>reflection</tt>, which is an instance of <tt>ActiveRecord::Reflection::AssociationReflection</tt>. + # + # For example, given + # + # class Blog < ActiveRecord::Base + # has_many :posts + # end + # + # blog = Blog.first + # + # The association of <tt>blog.posts</tt> has the object +blog+ as its + # <tt>owner</tt>, the collection of its posts as <tt>target</tt>, and + # the <tt>reflection</tt> object represents a <tt>:has_many</tt> macro. class Association #:nodoc: attr_reader :owner, :target, :reflection - attr_accessor :inversed delegate :options, to: :reflection @@ -41,7 +57,9 @@ module ActiveRecord end # Reloads the \target and returns +self+ on success. - def reload + # The QueryCache is cleared if +force+ is true. + def reload(force = false) + klass.connection.clear_query_cache if force && klass reset reset_scope load_target @@ -67,7 +85,7 @@ module ActiveRecord # # Note that if the target has not been loaded, it is not considered stale. def stale_target? - !inversed && loaded? && @stale_state != stale_state + !@inversed && loaded? && @stale_state != stale_state end # Sets the target of this association to <tt>\target</tt>, and the \loaded flag to +true+. @@ -80,53 +98,44 @@ module ActiveRecord target_scope.merge!(association_scope) end - # The scope for this association. - # - # Note that the association_scope is merged into the target_scope only when the - # scope method is called. This is because at that point the call may be surrounded - # by scope.scoping { ... } or with_scope { ... } etc, which affects the scope which - # actually gets built. - def association_scope - if klass - @association_scope ||= AssociationScope.scope(self) - end - end - def reset_scope @association_scope = nil end # Set the inverse association, if possible def set_inverse_instance(record) - if invertible_for?(record) - inverse = record.association(inverse_reflection_for(record).name) - inverse.target = owner - inverse.inversed = true + if inverse = inverse_association_for(record) + inverse.inversed_from(owner) + end + record + end + + def set_inverse_instance_from_queries(record) + if inverse = inverse_association_for(record) + inverse.inversed_from_queries(owner) end record end # Remove the inverse association, if possible def remove_inverse_instance(record) - if invertible_for?(record) - inverse = record.association(inverse_reflection_for(record).name) - inverse.target = nil - inverse.inversed = false + if inverse = inverse_association_for(record) + inverse.inversed_from(nil) end end + def inversed_from(record) + self.target = record + @inversed = !!record + end + alias :inversed_from_queries :inversed_from + # Returns the class of the target. belongs_to polymorphic overrides this to look at the # polymorphic_type field on the owner. def klass reflection.klass end - # Can be overridden (i.e. in ThroughAssociation) to merge in other scopes (i.e. the - # through association's scope) - def target_scope - AssociationRelation.create(klass, self).merge!(klass.all) - end - def extensions extensions = klass.default_extensions | reflection.extensions @@ -187,6 +196,38 @@ module ActiveRecord end private + def find_target + scope = self.scope + return scope.to_a if skip_statement_cache?(scope) + + conn = klass.connection + sc = reflection.association_scope_cache(conn, owner) do |params| + as = AssociationScope.create { params.bind } + target_scope.merge!(as.scope(self)) + end + + binds = AssociationScope.get_bind_values(owner, reflection.chain) + sc.execute(binds, conn) { |record| set_inverse_instance(record) } || [] + end + + # The scope for this association. + # + # Note that the association_scope is merged into the target_scope only when the + # scope method is called. This is because at that point the call may be surrounded + # by scope.scoping { ... } or unscoped { ... } etc, which affects the scope which + # actually gets built. + def association_scope + if klass + @association_scope ||= AssociationScope.scope(self) + end + end + + # Can be overridden (i.e. in ThroughAssociation) to merge in other scopes (i.e. the + # through association's scope) + def target_scope + AssociationRelation.create(klass, self).merge!(klass.scope_for_association) + end + def scope_for_create scope.scope_for_create end @@ -240,6 +281,12 @@ module ActiveRecord end end + def inverse_association_for(record) + if invertible_for?(record) + record.association(inverse_reflection_for(record).name) + end + end + # Can be redefined by subclasses, notably polymorphic belongs_to # The record parameter is necessary to support polymorphic inverses as we must check for # the association in the specific class of the record. @@ -269,6 +316,7 @@ module ActiveRecord def build_record(attributes) reflection.build_association(attributes) do |record| initialize_attributes(record, attributes) + yield(record) if block_given? end end diff --git a/activerecord/lib/active_record/associations/association_scope.rb b/activerecord/lib/active_record/associations/association_scope.rb index 0a90a6104a..9e38380611 100644 --- a/activerecord/lib/active_record/associations/association_scope.rb +++ b/activerecord/lib/active_record/associations/association_scope.rb @@ -26,7 +26,9 @@ module ActiveRecord chain = get_chain(reflection, association, scope.alias_tracker) scope.extending! reflection.extensions - add_constraints(scope, owner, chain) + scope = add_constraints(scope, owner, chain) + scope.limit!(1) unless reflection.collection? + scope end def self.get_bind_values(owner, chain) diff --git a/activerecord/lib/active_record/associations/belongs_to_association.rb b/activerecord/lib/active_record/associations/belongs_to_association.rb index 1109fee462..3346725f2d 100644 --- a/activerecord/lib/active_record/associations/belongs_to_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_association.rb @@ -16,20 +16,7 @@ module ActiveRecord end end - def replace(record) - if record - raise_on_type_mismatch!(record) - update_counters_on_replace(record) - set_inverse_instance(record) - @updated = true - else - decrement_counters - end - - self.target = record - end - - def target=(record) + def inversed_from(record) replace_keys(record) super end @@ -47,26 +34,60 @@ module ActiveRecord @updated end - def decrement_counters # :nodoc: + def decrement_counters update_counters(-1) end - def increment_counters # :nodoc: + def increment_counters update_counters(1) end + def decrement_counters_before_last_save + if reflection.polymorphic? + model_was = owner.attribute_before_last_save(reflection.foreign_type).try(:constantize) + else + model_was = klass + end + + foreign_key_was = owner.attribute_before_last_save(reflection.foreign_key) + + if foreign_key_was && model_was < ActiveRecord::Base + update_counters_via_scope(model_was, foreign_key_was, -1) + end + end + + def target_changed? + owner.saved_change_to_attribute?(reflection.foreign_key) + end + private + def replace(record) + if record + raise_on_type_mismatch!(record) + set_inverse_instance(record) + @updated = true + end + + replace_keys(record) + + self.target = record + end def update_counters(by) if require_counter_update? && foreign_key_present? if target && !stale_target? target.increment!(reflection.counter_cache_column, by, touch: reflection.options[:touch]) else - klass.update_counters(target_id, reflection.counter_cache_column => by, touch: reflection.options[:touch]) + update_counters_via_scope(klass, owner._read_attribute(reflection.foreign_key), by) end end end + def update_counters_via_scope(klass, foreign_key, by) + scope = klass.unscoped.where!(primary_key(klass) => foreign_key) + scope.update_counters(reflection.counter_cache_column => by, touch: reflection.options[:touch]) + end + def find_target? !loaded? && foreign_key_present? && klass end @@ -75,22 +96,12 @@ module ActiveRecord reflection.counter_cache_column && owner.persisted? end - def update_counters_on_replace(record) - if require_counter_update? && different_target?(record) - owner.instance_variable_set :@_after_replace_counter_called, true - record.increment!(reflection.counter_cache_column) - decrement_counters - end - end - - # Checks whether record is different to the current target, without loading it - def different_target?(record) - record.id != owner._read_attribute(reflection.foreign_key) + def replace_keys(record) + owner[reflection.foreign_key] = record ? record._read_attribute(primary_key(record.class)) : nil end - def replace_keys(record) - owner[reflection.foreign_key] = record ? - record._read_attribute(reflection.association_primary_key(record.class)) : nil + def primary_key(klass) + reflection.association_primary_key(klass) end def foreign_key_present? @@ -104,14 +115,6 @@ module ActiveRecord inverse && inverse.has_one? end - def target_id - if options[:primary_key] - owner.send(reflection.name).try(:id) - else - owner._read_attribute(reflection.foreign_key) - end - end - def stale_state result = owner._read_attribute(reflection.foreign_key) { |n| owner.send(:missing_attribute, n, caller) } result && result.to_s diff --git a/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb b/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb index 75b4c4481a..9ae452e7a1 100644 --- a/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb @@ -9,17 +9,16 @@ module ActiveRecord type.presence && type.constantize end - private + def target_changed? + super || owner.saved_change_to_attribute?(reflection.foreign_type) + end + private def replace_keys(record) super owner[reflection.foreign_type] = record ? record.class.polymorphic_name : nil end - def different_target?(record) - super || record.class != klass - end - def inverse_reflection_for(record) reflection.polymorphic_inverse_of(record.class) end diff --git a/activerecord/lib/active_record/associations/builder/belongs_to.rb b/activerecord/lib/active_record/associations/builder/belongs_to.rb index c161454c1a..fc00f1e900 100644 --- a/activerecord/lib/active_record/associations/builder/belongs_to.rb +++ b/activerecord/lib/active_record/associations/builder/belongs_to.rb @@ -21,50 +21,16 @@ module ActiveRecord::Associations::Builder # :nodoc: add_default_callbacks(model, reflection) if reflection.options[:default] end - def self.define_accessors(mixin, reflection) - super - add_counter_cache_methods mixin - end - - def self.add_counter_cache_methods(mixin) - return if mixin.method_defined? :belongs_to_counter_cache_after_update - - mixin.class_eval do - def belongs_to_counter_cache_after_update(reflection) - foreign_key = reflection.foreign_key - cache_column = reflection.counter_cache_column - - if (@_after_replace_counter_called ||= false) - @_after_replace_counter_called = false - elsif saved_change_to_attribute?(foreign_key) && !new_record? - if reflection.polymorphic? - model = attribute_in_database(reflection.foreign_type).try(:constantize) - model_was = attribute_before_last_save(reflection.foreign_type).try(:constantize) - else - model = reflection.klass - model_was = reflection.klass - end - - foreign_key_was = attribute_before_last_save foreign_key - foreign_key = attribute_in_database foreign_key - - if foreign_key && model.respond_to?(:increment_counter) - model.increment_counter(cache_column, foreign_key) - end - - if foreign_key_was && model_was.respond_to?(:decrement_counter) - model_was.decrement_counter(cache_column, foreign_key_was) - end - end - end - end - end - def self.add_counter_cache_callbacks(model, reflection) cache_column = reflection.counter_cache_column model.after_update lambda { |record| - record.belongs_to_counter_cache_after_update(reflection) + association = association(reflection.name) + + if association.target_changed? + association.increment_counters + association.decrement_counters_before_last_save + end } klass = reflection.class_name.safe_constantize @@ -84,7 +50,8 @@ module ActiveRecord::Associations::Builder # :nodoc: else klass = association.klass end - old_record = klass.find_by(klass.primary_key => old_foreign_id) + primary_key = reflection.association_primary_key(klass) + old_record = klass.find_by(primary_key => old_foreign_id) if old_record if touch != true @@ -114,12 +81,18 @@ module ActiveRecord::Associations::Builder # :nodoc: BelongsTo.touch_record(record, record.send(changes_method), foreign_key, n, touch, belongs_to_touch_method) }} - unless reflection.counter_cache_column + if reflection.counter_cache_column + touch_callback = callback.(:saved_changes) + update_callback = lambda { |record| + instance_exec(record, &touch_callback) unless association(reflection.name).target_changed? + } + model.after_update update_callback, if: :saved_changes? + else model.after_create callback.(:saved_changes), if: :saved_changes? + model.after_update callback.(:saved_changes), if: :saved_changes? model.after_destroy callback.(:changes_to_save) end - model.after_update callback.(:saved_changes), if: :saved_changes? model.after_touch callback.(:changes_to_save) end diff --git a/activerecord/lib/active_record/associations/builder/collection_association.rb b/activerecord/lib/active_record/associations/builder/collection_association.rb index 35a72c3850..5848cd9112 100644 --- a/activerecord/lib/active_record/associations/builder/collection_association.rb +++ b/activerecord/lib/active_record/associations/builder/collection_association.rb @@ -20,11 +20,11 @@ module ActiveRecord::Associations::Builder # :nodoc: } end - def self.define_extensions(model, name) + def self.define_extensions(model, name, &block) if block_given? extension_module_name = "#{model.name.demodulize}#{name.to_s.camelize}AssociationExtension" - extension = Module.new(&Proc.new) - model.parent.const_set(extension_module_name, extension) + extension = Module.new(&block) + model.module_parent.const_set(extension_module_name, extension) end end diff --git a/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb b/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb index 1981da11a2..0140aa15c8 100644 --- a/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb +++ b/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb @@ -2,39 +2,6 @@ module ActiveRecord::Associations::Builder # :nodoc: class HasAndBelongsToMany # :nodoc: - class JoinTableResolver # :nodoc: - KnownTable = Struct.new :join_table - - class KnownClass # :nodoc: - def initialize(lhs_class, rhs_class_name) - @lhs_class = lhs_class - @rhs_class_name = rhs_class_name - @join_table = nil - end - - def join_table - @join_table ||= [@lhs_class.table_name, klass.table_name].sort.join("\0").gsub(/^(.*[._])(.+)\0\1(.+)/, '\1\2_\3').tr("\0", "_") - end - - private - - def klass - @lhs_class.send(:compute_type, @rhs_class_name) - end - end - - def self.build(lhs_class, name, options) - if options[:join_table] - KnownTable.new options[:join_table].to_s - else - class_name = options.fetch(:class_name) { - name.to_s.camelize.singularize - } - KnownClass.new lhs_class, class_name.to_s - end - end - end - attr_reader :lhs_model, :association_name, :options def initialize(association_name, lhs_model, options) @@ -44,8 +11,6 @@ module ActiveRecord::Associations::Builder # :nodoc: end def through_model - habtm = JoinTableResolver.build lhs_model, association_name, options - join_model = Class.new(ActiveRecord::Base) { class << self attr_accessor :left_model @@ -56,7 +21,9 @@ module ActiveRecord::Associations::Builder # :nodoc: end def self.table_name - table_name_resolver.join_table + # Table name needs to be resolved lazily + # because RHS class might not have been loaded + @table_name ||= table_name_resolver.call end def self.compute_type(class_name) @@ -86,7 +53,7 @@ module ActiveRecord::Associations::Builder # :nodoc: } join_model.name = "HABTM_#{association_name.to_s.camelize}" - join_model.table_name_resolver = habtm + join_model.table_name_resolver = -> { table_name } join_model.left_model = lhs_model join_model.add_left_association :left_side, anonymous_class: lhs_model @@ -96,7 +63,7 @@ module ActiveRecord::Associations::Builder # :nodoc: def middle_reflection(join_model) middle_name = [lhs_model.name.downcase.pluralize, - association_name].join("_".freeze).gsub("::".freeze, "_".freeze).to_sym + association_name].join("_").gsub("::", "_").to_sym middle_options = middle_options join_model HasMany.create_reflection(lhs_model, @@ -117,6 +84,18 @@ module ActiveRecord::Associations::Builder # :nodoc: middle_options end + def table_name + if options[:join_table] + options[:join_table].to_s + else + class_name = options.fetch(:class_name) { + association_name.to_s.camelize.singularize + } + klass = lhs_model.send(:compute_type, class_name.to_s) + [lhs_model.table_name, klass.table_name].sort.join("\0").gsub(/^(.*[._])(.+)\0\1(.+)/, '\1\2_\3').tr("\0", "_") + end + end + def belongs_to_options(options) rhs_options = {} diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb index 443ccaaa72..c3d4eab562 100644 --- a/activerecord/lib/active_record/associations/collection_association.rb +++ b/activerecord/lib/active_record/associations/collection_association.rb @@ -45,6 +45,8 @@ module ActiveRecord def ids_reader if loaded? target.pluck(reflection.association_primary_key) + elsif !target.empty? + load_target.pluck(reflection.association_primary_key) else @association_ids ||= scope.pluck(reflection.association_primary_key) end @@ -103,15 +105,12 @@ module ActiveRecord if attributes.is_a?(Array) attributes.collect { |attr| build(attr, &block) } else - add_to_target(build_record(attributes)) do |record| - yield(record) if block_given? - end + add_to_target(build_record(attributes, &block)) end end - # Add +records+ to this association. Returns +self+ so method calls may - # be chained. Since << flattens its argument list and inserts each record, - # +push+ and +concat+ behave identically. + # Add +records+ to this association. Since +<<+ flattens its argument list + # and inserts each record, +push+ and +concat+ behave identically. def concat(*records) records = records.flatten if owner.new_record? @@ -212,9 +211,11 @@ module ActiveRecord def size if !find_target? || loaded? target.size + elsif @association_ids + @association_ids.size elsif !association_scope.group_values.empty? load_target.size - elsif !association_scope.distinct_value && target.is_a?(Array) + elsif !association_scope.distinct_value && !target.empty? unsaved_records = target.select(&:new_record?) unsaved_records.size + count_records else @@ -231,10 +232,10 @@ module ActiveRecord # loaded and you are going to fetch the records anyway it is better to # check <tt>collection.length.zero?</tt>. def empty? - if loaded? + if loaded? || @association_ids || reflection.has_cached_counter? size.zero? else - @target.blank? && !scope.exists? + target.empty? && !scope.exists? end end @@ -301,23 +302,6 @@ module ActiveRecord end private - - def find_target - scope = self.scope - return scope.to_a if skip_statement_cache?(scope) - - conn = klass.connection - sc = reflection.association_scope_cache(conn, owner) do |params| - as = AssociationScope.create { params.bind } - target_scope.merge!(as.scope(self)) - end - - binds = AssociationScope.get_bind_values(owner, reflection.chain) - sc.execute(binds, conn) do |record| - set_inverse_instance(record) - end - end - # We have some records loaded from the database (persisted) and some that are # in-memory (memory). The same record may be represented in the persisted array # and in the memory array. @@ -356,15 +340,17 @@ module ActiveRecord if attributes.is_a?(Array) attributes.collect { |attr| _create_record(attr, raise, &block) } else + record = build_record(attributes, &block) transaction do - add_to_target(build_record(attributes)) do |record| - yield(record) if block_given? - insert_record(record, true, raise) { + result = nil + add_to_target(record) do + result = insert_record(record, true, raise) { @_was_loaded = loaded? - @association_ids = nil } end + raise ActiveRecord::Rollback unless result end + record end end @@ -395,7 +381,8 @@ module ActiveRecord records.each { |record| callback(:before_remove, record) } delete_records(existing_records, method) if existing_records.any? - records.each { |record| target.delete(record) } + @target -= records + @association_ids = nil records.each { |record| callback(:after_remove, record) } end @@ -408,9 +395,9 @@ module ActiveRecord end def replace_records(new_target, original_target) - delete(target - new_target) + delete(difference(target, new_target)) - unless concat(new_target - target) + unless concat(difference(new_target, target)) @target = original_target raise RecordNotSaved, "Failed to replace #{reflection.name} because one or more of the " \ "new records could not be saved." @@ -420,7 +407,7 @@ module ActiveRecord end def replace_common_records_in_memory(new_target, original_target) - common_records = new_target & original_target + common_records = intersection(new_target, original_target) common_records.each do |record| skip_callbacks = true replace_on_target(record, @target.index(record), skip_callbacks) @@ -436,13 +423,14 @@ module ActiveRecord unless owner.new_record? result &&= insert_record(record, true, raise) { @_was_loaded = loaded? - @association_ids = nil } end end end - result && records + raise ActiveRecord::Rollback unless result + + records end def replace_on_target(record, index, skip_callbacks) @@ -457,6 +445,7 @@ module ActiveRecord if index target[index] = record elsif @_was_loaded || !loaded? + @association_ids = nil target << record end diff --git a/activerecord/lib/active_record/associations/collection_proxy.rb b/activerecord/lib/active_record/associations/collection_proxy.rb index 9a30198b95..edcb44f0fc 100644 --- a/activerecord/lib/active_record/associations/collection_proxy.rb +++ b/activerecord/lib/active_record/associations/collection_proxy.rb @@ -2,11 +2,8 @@ module ActiveRecord module Associations - # Association proxies in Active Record are middlemen between the object that - # holds the association, known as the <tt>@owner</tt>, and the actual associated - # object, known as the <tt>@target</tt>. The kind of association any proxy is - # about is available in <tt>@reflection</tt>. That's an instance of the class - # ActiveRecord::Reflection::AssociationReflection. + # Collection proxies in Active Record are middlemen between an + # <tt>association</tt>, and its <tt>target</tt> result set. # # For example, given # @@ -16,14 +13,14 @@ module ActiveRecord # # blog = Blog.first # - # the association proxy in <tt>blog.posts</tt> has the object in +blog+ as - # <tt>@owner</tt>, the collection of its posts as <tt>@target</tt>, and - # the <tt>@reflection</tt> object represents a <tt>:has_many</tt> macro. + # The collection proxy returned by <tt>blog.posts</tt> is built from a + # <tt>:has_many</tt> <tt>association</tt>, and delegates to a collection + # of posts as the <tt>target</tt>. # - # This class delegates unknown methods to <tt>@target</tt> via - # <tt>method_missing</tt>. + # This class delegates unknown methods to the <tt>association</tt>'s + # relation class via a delegate cache. # - # The <tt>@target</tt> object is not \loaded until needed. For example, + # The <tt>target</tt> result set is not loaded until needed. For example, # # blog.posts.count # @@ -366,34 +363,6 @@ module ActiveRecord @association.create!(attributes, &block) end - # Add one or more records to the collection by setting their foreign keys - # to the association's primary key. Since #<< flattens its argument list and - # inserts each record, +push+ and #concat behave identically. Returns +self+ - # so method calls may be chained. - # - # class Person < ActiveRecord::Base - # has_many :pets - # end - # - # person.pets.size # => 0 - # person.pets.concat(Pet.new(name: 'Fancy-Fancy')) - # person.pets.concat(Pet.new(name: 'Spook'), Pet.new(name: 'Choo-Choo')) - # person.pets.size # => 3 - # - # person.id # => 1 - # person.pets - # # => [ - # # #<Pet id: 1, name: "Fancy-Fancy", person_id: 1>, - # # #<Pet id: 2, name: "Spook", person_id: 1>, - # # #<Pet id: 3, name: "Choo-Choo", person_id: 1> - # # ] - # - # person.pets.concat([Pet.new(name: 'Brain'), Pet.new(name: 'Benny')]) - # person.pets.size # => 5 - def concat(*records) - @association.concat(*records) - end - # Replaces this collection with +other_array+. This will perform a diff # and delete/add only records that have changed. # @@ -500,7 +469,7 @@ module ActiveRecord # Pet.find(1, 2, 3) # # => ActiveRecord::RecordNotFound: Couldn't find all Pets with 'id': (1, 2, 3) def delete_all(dependent = nil) - @association.delete_all(dependent) + @association.delete_all(dependent).tap { reset_scope } end # Deletes the records of the collection directly from the database @@ -527,7 +496,7 @@ module ActiveRecord # # Pet.find(1) # => Couldn't find Pet with id=1 def destroy_all - @association.destroy_all + @association.destroy_all.tap { reset_scope } end # Deletes the +records+ supplied from the collection according to the strategy @@ -646,7 +615,7 @@ module ActiveRecord # # #<Pet id: 3, name: "Choo-Choo", person_id: 1> # # ] def delete(*records) - @association.delete(*records) + @association.delete(*records).tap { reset_scope } end # Destroys the +records+ supplied and removes them from the collection. @@ -718,7 +687,7 @@ module ActiveRecord # # Pet.find(4, 5, 6) # => ActiveRecord::RecordNotFound: Couldn't find all Pets with 'id': (4, 5, 6) def destroy(*records) - @association.destroy(*records) + @association.destroy(*records).tap { reset_scope } end ## @@ -1033,8 +1002,9 @@ module ActiveRecord end # Adds one or more +records+ to the collection by setting their foreign keys - # to the association's primary key. Returns +self+, so several appends may be - # chained together. + # to the association's primary key. Since +<<+ flattens its argument list and + # inserts each record, +push+ and +concat+ behave identically. Returns +self+ + # so several appends may be chained together. # # class Person < ActiveRecord::Base # has_many :pets @@ -1057,6 +1027,7 @@ module ActiveRecord end alias_method :push, :<< alias_method :append, :<< + alias_method :concat, :<< def prepend(*args) raise NoMethodError, "prepend on association is not defined. Please use <<, push or append" @@ -1088,7 +1059,7 @@ module ActiveRecord # person.pets.reload # fetches pets from the database # # => [#<Pet id: 1, name: "Snoop", group: "dogs", person_id: 1>] def reload - proxy_association.reload + proxy_association.reload(true) reset_scope end @@ -1125,7 +1096,7 @@ module ActiveRecord SpawnMethods, ].flat_map { |klass| klass.public_instance_methods(false) - } - self.public_instance_methods(false) - [:select] + [:scoping] + } - self.public_instance_methods(false) - [:select] + [:scoping, :values] delegate(*delegate_methods, to: :scope) diff --git a/activerecord/lib/active_record/associations/foreign_association.rb b/activerecord/lib/active_record/associations/foreign_association.rb index 40010cde03..59af6f54c3 100644 --- a/activerecord/lib/active_record/associations/foreign_association.rb +++ b/activerecord/lib/active_record/associations/foreign_association.rb @@ -9,5 +9,12 @@ module ActiveRecord::Associations false end end + + def nullified_owner_attributes + Hash.new.tap do |attrs| + attrs[reflection.foreign_key] = nil + attrs[reflection.type] = nil if reflection.type.present? + end + end end end diff --git a/activerecord/lib/active_record/associations/has_many_association.rb b/activerecord/lib/active_record/associations/has_many_association.rb index cf85a87fa7..5972846940 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -36,14 +36,6 @@ module ActiveRecord super end - def empty? - if reflection.has_cached_counter? - size.zero? - else - super - end - end - private # Returns the number of records in this collection. @@ -69,7 +61,7 @@ module ActiveRecord # If there's nothing in the database and @target has no new records # we are certain the current target is an empty array. This is a # documented side-effect of the method that may avoid an extra SELECT. - (@target ||= []) && loaded! if count == 0 + loaded! if count == 0 [association_scope.limit_value, count].compact.min end @@ -92,13 +84,14 @@ module ActiveRecord if method == :delete_all scope.delete_all else - scope.update_all(reflection.foreign_key => nil) + scope.update_all(nullified_owner_attributes) end end def delete_or_nullify_all_records(method) count = delete_count(method, scope) update_counter(-count) + count end # Deletes the records according to the <tt>:dependent</tt> option. @@ -130,6 +123,14 @@ module ActiveRecord end saved_successfully end + + def difference(a, b) + a - b + end + + def intersection(a, b) + a & b + end end end end diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index 59929b8c4e..0d384950fe 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -21,20 +21,6 @@ module ActiveRecord super end - def concat_records(records) - ensure_not_nested - - records = super(records, true) - - if owner.new_record? && records - records.flatten.each do |record| - build_through_record(record) - end - end - - records - end - def insert_record(record, validate = true, raise = false) ensure_not_nested @@ -48,6 +34,20 @@ module ActiveRecord end private + def concat_records(records) + ensure_not_nested + + records = super(records, true) + + if owner.new_record? && records + records.flatten.each do |record| + build_through_record(record) + end + end + + records + end + # The through record (built with build_record) is temporarily cached # so that it may be reused if insert_record is subsequently called. # @@ -57,21 +57,14 @@ module ActiveRecord @through_records[record.object_id] ||= begin ensure_mutable - through_record = through_association.build(*options_for_through_record) - through_record.send("#{source_reflection.name}=", record) + attributes = through_scope_attributes + attributes[source_reflection.name] = record + attributes[source_reflection.foreign_type] = options[:source_type] if options[:source_type] - if options[:source_type] - through_record.send("#{source_reflection.foreign_type}=", options[:source_type]) - end - - through_record + through_association.build(attributes) end end - def options_for_through_record - [through_scope_attributes] - end - def through_scope_attributes scope.where_values_hash(through_association.reflection.name.to_s). except!(through_association.reflection.foreign_key, @@ -90,7 +83,7 @@ module ActiveRecord def build_record(attributes) ensure_not_nested - record = super(attributes) + record = super inverse = source_reflection.inverse_of if inverse @@ -161,6 +154,30 @@ module ActiveRecord else update_counter(-count) end + + count + end + + def difference(a, b) + distribution = distribution(b) + + a.reject { |record| mark_occurrence(distribution, record) } + end + + def intersection(a, b) + distribution = distribution(b) + + a.select { |record| mark_occurrence(distribution, record) } + end + + def mark_occurrence(distribution, record) + distribution[record] > 0 && distribution[record] -= 1 + end + + def distribution(array) + array.each_with_object(Hash.new(0)) do |record, distribution| + distribution[record] += 1 + end end def through_records_for(record) diff --git a/activerecord/lib/active_record/associations/has_one_association.rb b/activerecord/lib/active_record/associations/has_one_association.rb index 090b082cb0..99971286a3 100644 --- a/activerecord/lib/active_record/associations/has_one_association.rb +++ b/activerecord/lib/active_record/associations/has_one_association.rb @@ -23,35 +23,6 @@ module ActiveRecord end end - def replace(record, save = true) - raise_on_type_mismatch!(record) if record - load_target - - return target unless target || record - - assigning_another_record = target != record - if assigning_another_record || record.has_changes_to_save? - save &&= owner.persisted? - - transaction_if(save) do - remove_target!(options[:dependent]) if target && !target.destroyed? && assigning_another_record - - if record - set_owner_attributes(record) - set_inverse_instance(record) - - if save && !record.save - nullify_owner_attributes(record) - set_owner_attributes(target) if target - raise RecordNotSaved, "Failed to save the new associated #{reflection.name}." - end - end - end - end - - self.target = record - end - def delete(method = options[:dependent]) if load_target case method @@ -62,12 +33,39 @@ module ActiveRecord target.destroy throw(:abort) unless target.destroyed? when :nullify - target.update_columns(reflection.foreign_key => nil) if target.persisted? + target.update_columns(nullified_owner_attributes) if target.persisted? end end end private + def replace(record, save = true) + raise_on_type_mismatch!(record) if record + + return target unless load_target || record + + assigning_another_record = target != record + if assigning_another_record || record.has_changes_to_save? + save &&= owner.persisted? + + transaction_if(save) do + remove_target!(options[:dependent]) if target && !target.destroyed? && assigning_another_record + + if record + set_owner_attributes(record) + set_inverse_instance(record) + + if save && !record.save + nullify_owner_attributes(record) + set_owner_attributes(target) if target + raise RecordNotSaved, "Failed to save the new associated #{reflection.name}." + end + end + end + end + + self.target = record + end # The reason that the save param for replace is false, if for create (not just build), # is because the setting of the foreign keys is actually handled by the scoping when @@ -107,6 +105,14 @@ module ActiveRecord yield end end + + def _create_record(attributes, raise_error = false, &block) + unless owner.persisted? + raise ActiveRecord::RecordNotSaved, "You cannot call create unless the parent is saved" + end + + super + end end end end diff --git a/activerecord/lib/active_record/associations/has_one_through_association.rb b/activerecord/lib/active_record/associations/has_one_through_association.rb index 491282adf7..10978b2d93 100644 --- a/activerecord/lib/active_record/associations/has_one_through_association.rb +++ b/activerecord/lib/active_record/associations/has_one_through_association.rb @@ -6,12 +6,12 @@ module ActiveRecord class HasOneThroughAssociation < HasOneAssociation #:nodoc: include ThroughAssociation - def replace(record, save = true) - create_through_record(record, save) - self.target = record - end - private + def replace(record, save = true) + create_through_record(record, save) + self.target = record + end + def create_through_record(record, save) ensure_not_nested @@ -28,7 +28,11 @@ module ActiveRecord end if through_record - through_record.update(attributes) + if through_record.new_record? + through_record.assign_attributes(attributes) + else + through_record.update(attributes) + end elsif owner.new_record? || !save through_proxy.build(attributes) else diff --git a/activerecord/lib/active_record/associations/join_dependency.rb b/activerecord/lib/active_record/associations/join_dependency.rb index f88e383fe0..b76005b587 100644 --- a/activerecord/lib/active_record/associations/join_dependency.rb +++ b/activerecord/lib/active_record/associations/join_dependency.rb @@ -14,10 +14,8 @@ module ActiveRecord i[column.name] = column.alias } } - @name_and_alias_cache = tables.each_with_object({}) { |table, h| - h[table.node] = table.columns.map { |column| - [column.name, column.alias] - } + @columns_cache = tables.each_with_object({}) { |table, h| + h[table.node] = table.columns } end @@ -25,9 +23,8 @@ module ActiveRecord @tables.flat_map(&:column_aliases) end - # An array of [column_name, alias] pairs for the table def column_aliases(node) - @name_and_alias_cache[node] + @columns_cache[node] end def column_alias(node, column) @@ -67,42 +64,31 @@ module ActiveRecord end end - def initialize(base, table, associations, alias_tracker) - @alias_tracker = alias_tracker + def initialize(base, table, associations) tree = self.class.make_tree associations @join_root = JoinBase.new(base, table, build(tree, base)) - @join_root.children.each { |child| construct_tables! @join_root, child } end def reflections join_root.drop(1).map!(&:reflection) end - def join_constraints(joins_to_add, join_type) - joins = join_root.children.flat_map { |child| - make_join_constraints(join_root, child, join_type) - } + def join_constraints(joins_to_add, join_type, alias_tracker) + @alias_tracker = alias_tracker + + construct_tables!(join_root) + joins = make_join_constraints(join_root, join_type) joins.concat joins_to_add.flat_map { |oj| + construct_tables!(oj.join_root) if join_root.match? oj.join_root walk join_root, oj.join_root else - oj.join_root.children.flat_map { |child| - make_join_constraints(oj.join_root, child, join_type) - } + make_join_constraints(oj.join_root, join_type) end } end - def aliases - @aliases ||= Aliases.new join_root.each_with_index.map { |join_part, i| - columns = join_part.column_names.each_with_index.map { |column_name, j| - Aliases::Column.new column_name, "t#{i}_r#{j}" - } - Aliases::Table.new(join_part, columns) - } - end - def instantiate(result_set, &block) primary_key = aliases.column_alias(join_root, join_root.primary_key) @@ -127,35 +113,49 @@ module ActiveRecord result_set.each { |row_hash| parent_key = primary_key ? row_hash[primary_key] : row_hash parent = parents[parent_key] ||= join_root.instantiate(row_hash, column_aliases, &block) - construct(parent, join_root, row_hash, result_set, seen, model_cache, aliases) + construct(parent, join_root, row_hash, seen, model_cache) } end parents.values end + def apply_column_aliases(relation) + relation._select!(-> { aliases.columns }) + end + protected - attr_reader :alias_tracker, :base_klass, :join_root + attr_reader :join_root private + attr_reader :alias_tracker - def make_constraints(parent, child, tables, join_type) - chain = child.reflection.chain - foreign_table = parent.table - foreign_klass = parent.base_klass - child.join_constraints(foreign_table, foreign_klass, join_type, tables, chain) + def aliases + @aliases ||= Aliases.new join_root.each_with_index.map { |join_part, i| + columns = join_part.column_names.each_with_index.map { |column_name, j| + Aliases::Column.new column_name, "t#{i}_r#{j}" + } + Aliases::Table.new(join_part, columns) + } end - def make_outer_joins(parent, child) - join_type = Arel::Nodes::OuterJoin - make_join_constraints(parent, child, join_type, true) + def construct_tables!(join_root) + join_root.each_children do |parent, child| + child.tables = table_aliases_for(parent, child) + end end - def make_join_constraints(parent, child, join_type, aliasing = false) - tables = aliasing ? table_aliases_for(parent, child) : child.tables - joins = make_constraints(parent, child, tables, join_type) + def make_join_constraints(join_root, join_type) + join_root.children.flat_map do |child| + make_constraints(join_root, child, join_type) + end + end - joins.concat child.children.flat_map { |c| make_join_constraints(child, c, join_type, aliasing) } + def make_constraints(parent, child, join_type = Arel::Nodes::OuterJoin) + foreign_table = parent.table + foreign_klass = parent.base_klass + joins = child.join_constraints(foreign_table, foreign_klass, join_type, alias_tracker) + joins.concat child.children.flat_map { |c| make_constraints(child, c, join_type) } end def table_aliases_for(parent, node) @@ -168,13 +168,8 @@ module ActiveRecord } end - def construct_tables!(parent, node) - node.tables = table_aliases_for(parent, node) - node.children.each { |child| construct_tables! node, child } - end - def table_alias_for(reflection, parent, join) - name = "#{reflection.plural_name}_#{parent.table_name}" + name = reflection.alias_candidate(parent.table_name) join ? "#{name}_join" : name end @@ -183,8 +178,8 @@ module ActiveRecord [left.children.find { |node2| node1.match? node2 }, node1] }.partition(&:first) - ojs = missing.flat_map { |_, n| make_outer_joins left, n } - intersection.flat_map { |l, r| walk l, r }.concat ojs + joins = intersection.flat_map { |l, r| r.table = l.table; walk(l, r) } + joins.concat missing.flat_map { |_, n| make_constraints(left, n) } end def find_reflection(klass, name) @@ -202,11 +197,11 @@ module ActiveRecord raise EagerLoadPolymorphicError.new(reflection) end - JoinAssociation.new(reflection, build(right, reflection.klass), alias_tracker) + JoinAssociation.new(reflection, build(right, reflection.klass)) end end - def construct(ar_parent, parent, row, rs, seen, model_cache, aliases) + def construct(ar_parent, parent, row, seen, model_cache) return if ar_parent.nil? parent.children.each do |node| @@ -215,7 +210,7 @@ module ActiveRecord other.loaded! elsif ar_parent.association_cached?(node.reflection.name) model = ar_parent.association(node.reflection.name).target - construct(model, node, row, rs, seen, model_cache, aliases) + construct(model, node, row, seen, model_cache) next end @@ -227,25 +222,20 @@ module ActiveRecord next end - model = seen[ar_parent.object_id][node.base_klass][id] + model = seen[ar_parent.object_id][node][id] if model - construct(model, node, row, rs, seen, model_cache, aliases) + construct(model, node, row, seen, model_cache) else - model = construct_model(ar_parent, node, row, model_cache, id, aliases) - - if node.reflection.scope && - node.reflection.scope_for(node.base_klass.unscoped).readonly_value - model.readonly! - end + model = construct_model(ar_parent, node, row, model_cache, id) - seen[ar_parent.object_id][node.base_klass][id] = model - construct(model, node, row, rs, seen, model_cache, aliases) + seen[ar_parent.object_id][node][id] = model + construct(model, node, row, seen, model_cache) end end end - def construct_model(record, node, row, model_cache, id, aliases) + def construct_model(record, node, row, model_cache, id) other = record.association(node.reflection.name) model = model_cache[node][id] ||= @@ -259,6 +249,7 @@ module ActiveRecord other.target = model end + model.readonly! if node.readonly? model end end diff --git a/activerecord/lib/active_record/associations/join_dependency/join_association.rb b/activerecord/lib/active_record/associations/join_dependency/join_association.rb index c36386ec7e..ca0305abbb 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_association.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -1,22 +1,20 @@ # frozen_string_literal: true require "active_record/associations/join_dependency/join_part" +require "active_support/core_ext/array/extract" module ActiveRecord module Associations class JoinDependency # :nodoc: class JoinAssociation < JoinPart # :nodoc: - # The reflection of the association represented - attr_reader :reflection + attr_reader :reflection, :tables + attr_accessor :table - attr_accessor :tables - - def initialize(reflection, children, alias_tracker) + def initialize(reflection, children) super(reflection.klass, children) - @alias_tracker = alias_tracker - @reflection = reflection - @tables = nil + @reflection = reflection + @tables = nil end def match?(other) @@ -24,27 +22,30 @@ module ActiveRecord super && reflection == other.reflection end - def join_constraints(foreign_table, foreign_klass, join_type, tables, chain) - joins = [] - tables = tables.reverse + def join_constraints(foreign_table, foreign_klass, join_type, alias_tracker) + joins = [] # The chain starts with the target table, but we want to end with it here (makes # more sense in this context), so we reverse - chain.reverse_each do |reflection| - table = tables.shift + reflection.chain.reverse_each.with_index(1) do |reflection, i| + table = tables[-i] klass = reflection.klass - constraint = reflection.build_join_constraint(table, foreign_table) - - joins << table.create_join(table, table.create_on(constraint), join_type) + join_scope = reflection.join_scope(table, foreign_table, foreign_klass) - join_scope = reflection.join_scope(table, foreign_klass) arel = join_scope.arel(alias_tracker.aliases) + nodes = arel.constraints.first + + others = nodes.children.extract! do |node| + Arel.fetch_attribute(node) { |attr| attr.relation.name != table.name } + end - if arel.constraints.any? + joins << table.create_join(table, table.create_on(nodes), join_type) + + unless others.empty? joins.concat arel.join_sources right = joins.last.right - right.expr = right.expr.and(arel.constraints) + right.expr.children.concat(others) end # The current table in this iteration becomes the foreign table in the next @@ -54,12 +55,16 @@ module ActiveRecord joins end - def table - tables.first + def tables=(tables) + @tables = tables + @table = tables.first end - private - attr_reader :alias_tracker + def readonly? + return @readonly if defined?(@readonly) + + @readonly = reflection.scope && reflection.scope_for(base_klass.unscoped).readonly_value + end end end end diff --git a/activerecord/lib/active_record/associations/join_dependency/join_part.rb b/activerecord/lib/active_record/associations/join_dependency/join_part.rb index 2181f308bf..3ad72a3646 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_part.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_part.rb @@ -33,6 +33,13 @@ module ActiveRecord children.each { |child| child.each(&block) } end + def each_children(&block) + children.each do |child| + yield self, child + child.each_children(&block) + end + end + # An Arel::Table for the active_record def table raise NotImplementedError @@ -47,8 +54,8 @@ module ActiveRecord length = column_names_with_alias.length while index < length - column_name, alias_name = column_names_with_alias[index] - hash[column_name] = row[alias_name] + column = column_names_with_alias[index] + hash[column.name] = row[column.alias] index += 1 end diff --git a/activerecord/lib/active_record/associations/preloader.rb b/activerecord/lib/active_record/associations/preloader.rb index 1ea0aeac3a..6b57e5093a 100644 --- a/activerecord/lib/active_record/associations/preloader.rb +++ b/activerecord/lib/active_record/associations/preloader.rb @@ -88,7 +88,6 @@ module ActiveRecord if records.empty? [] else - records.uniq! Array.wrap(associations).flat_map { |association| preloaders_on association, records, preload_scope } @@ -98,34 +97,34 @@ module ActiveRecord private # Loads all the given data into +records+ for the +association+. - def preloaders_on(association, records, scope) + def preloaders_on(association, records, scope, polymorphic_parent = false) case association when Hash - preloaders_for_hash(association, records, scope) - when Symbol - preloaders_for_one(association, records, scope) - when String - preloaders_for_one(association.to_sym, records, scope) + preloaders_for_hash(association, records, scope, polymorphic_parent) + when Symbol, String + preloaders_for_one(association, records, scope, polymorphic_parent) else raise ArgumentError, "#{association.inspect} was not recognized for preload" end end - def preloaders_for_hash(association, records, scope) + def preloaders_for_hash(association, records, scope, polymorphic_parent) association.flat_map { |parent, child| - loaders = preloaders_for_one parent, records, scope - - recs = loaders.flat_map(&:preloaded_records).uniq - loaders.concat Array.wrap(child).flat_map { |assoc| - preloaders_on assoc, recs, scope - } - loaders + grouped_records(parent, records, polymorphic_parent).flat_map do |reflection, reflection_records| + loaders = preloaders_for_reflection(reflection, reflection_records, scope) + recs = loaders.flat_map(&:preloaded_records) + child_polymorphic_parent = reflection && reflection.options[:polymorphic] + loaders.concat Array.wrap(child).flat_map { |assoc| + preloaders_on assoc, recs, scope, child_polymorphic_parent + } + loaders + end } end # Loads all the given data into +records+ for a singular +association+. # - # Functions by instantiating a preloader class such as Preloader::HasManyThrough and + # Functions by instantiating a preloader class such as Preloader::Association and # call the +run+ method for each passed in class in the +records+ argument. # # Not all records have the same class, so group then preload group on the reflection @@ -135,24 +134,25 @@ module ActiveRecord # Additionally, polymorphic belongs_to associations can have multiple associated # classes, depending on the polymorphic_type field. So we group by the classes as # well. - def preloaders_for_one(association, records, scope) - grouped_records(association, records).flat_map do |reflection, klasses| - klasses.map do |rhs_klass, rs| - loader = preloader_for(reflection, rs).new(rhs_klass, rs, reflection, scope) - loader.run self - loader + def preloaders_for_one(association, records, scope, polymorphic_parent) + grouped_records(association, records, polymorphic_parent) + .flat_map do |reflection, reflection_records| + preloaders_for_reflection reflection, reflection_records, scope end + end + + def preloaders_for_reflection(reflection, records, scope) + records.group_by { |record| record.association(reflection.name).klass }.map do |rhs_klass, rs| + preloader_for(reflection, rs).new(rhs_klass, rs, reflection, scope).run end end - def grouped_records(association, records) + def grouped_records(association, records, polymorphic_parent) h = {} records.each do |record| - next unless record - assoc = record.association(association) - next unless assoc.klass - klasses = h[assoc.reflection] ||= {} - (klasses[assoc.klass] ||= []) << record + reflection = record.class._reflect_on_association(association) + next if polymorphic_parent && !reflection || !record.association(association).klass + (h[reflection] ||= []) << record end h end @@ -163,10 +163,18 @@ module ActiveRecord @reflection = reflection end - def run(preloader); end + def run + self + end def preloaded_records - owners.flat_map { |owner| owner.association(reflection.name).target } + @preloaded_records ||= records_by_owner.flat_map(&:last) + end + + def records_by_owner + @records_by_owner ||= owners.each_with_object({}) do |owner, result| + result[owner] = Array(owner.association(reflection.name).target) + end end private diff --git a/activerecord/lib/active_record/associations/preloader/association.rb b/activerecord/lib/active_record/associations/preloader/association.rb index d6f7359055..46532f651e 100644 --- a/activerecord/lib/active_record/associations/preloader/association.rb +++ b/activerecord/lib/active_record/associations/preloader/association.rb @@ -4,26 +4,44 @@ module ActiveRecord module Associations class Preloader class Association #:nodoc: - attr_reader :preloaded_records - def initialize(klass, owners, reflection, preload_scope) @klass = klass @owners = owners @reflection = reflection @preload_scope = preload_scope @model = owners.first && owners.first.class - @preloaded_records = [] end - def run(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) + def run + if !preload_scope || preload_scope.empty_scope? + owners.each do |owner| + associate_records_to_owner(owner, records_by_owner[owner] || []) + end + else + # Custom preload scope is used and + # the association can not be marked as loaded + # Loading into a Hash instead + records_by_owner end + self + end - owners.each do |owner| - associate_records_to_owner(owner, records[convert_key(owner[owner_key_name])] || []) + def records_by_owner + @records_by_owner ||= preloaded_records.each_with_object({}) do |record, result| + owners_by_key[convert_key(record[association_key_name])].each do |owner| + (result[owner] ||= []) << record + end + end + end + + def preloaded_records + return @preloaded_records if defined?(@preloaded_records) + return [] if owner_keys.empty? + # Some databases impose a limit on the number of ids in a list (in Oracle it's 1000) + # Make several smaller queries if necessary or make one query if the adapter supports it + slices = owner_keys.each_slice(klass.connection.in_clause_length || owner_keys.size) + @preloaded_records = slices.flat_map do |slice| + records_for(slice) end end @@ -42,11 +60,10 @@ module ActiveRecord def associate_records_to_owner(owner, records) association = owner.association(reflection.name) - association.loaded! if reflection.collection? - association.target.concat(records) + association.target = records else - association.target = records.first unless records.empty? + association.target = records.first end end @@ -55,13 +72,10 @@ module ActiveRecord end def owners_by_key - unless defined?(@owners_by_key) - @owners_by_key = owners.each_with_object({}) do |owner, h| - key = convert_key(owner[owner_key_name]) - h[key] = owner if key - end + @owners_by_key ||= owners.each_with_object({}) do |owner, result| + key = convert_key(owner[owner_key_name]) + (result[key] ||= []) << owner if key end - @owners_by_key end def key_conversion_required? @@ -88,23 +102,16 @@ module ActiveRecord @model.type_for_attribute(owner_key_name).type end - def load_records(&block) - return {} if owner_keys.empty? - # Some databases impose a limit on the number of ids in a list (in Oracle it's 1000) - # Make several smaller queries if necessary or make one query if the adapter supports it - slices = owner_keys.each_slice(klass.connection.in_clause_length || owner_keys.size) - @preloaded_records = slices.flat_map do |slice| - records_for(slice, &block) - end - @preloaded_records.group_by do |record| - convert_key(record[association_key_name]) + def records_for(ids) + scope.where(association_key_name => ids).load do |record| + # Processing only the first owner + # because the record is modified but not an owner + owner = owners_by_key[convert_key(record[association_key_name])].first + association = owner.association(reflection.name) + association.set_inverse_instance(record) end end - def records_for(ids, &block) - scope.where(association_key_name => ids).load(&block) - end - def scope @scope ||= build_scope end @@ -116,7 +123,7 @@ module ActiveRecord def build_scope scope = klass.scope_for_association - if reflection.type + if reflection.type && !reflection.through_reflection? scope.where!(reflection.type => model.polymorphic_name) end diff --git a/activerecord/lib/active_record/associations/preloader/through_association.rb b/activerecord/lib/active_record/associations/preloader/through_association.rb index a6b7ab80a2..bec1c4c94a 100644 --- a/activerecord/lib/active_record/associations/preloader/through_association.rb +++ b/activerecord/lib/active_record/associations/preloader/through_association.rb @@ -4,42 +4,57 @@ module ActiveRecord module Associations class Preloader 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) - - owners.each do |owner| - through_records = Array(owner.association(through_reflection.name).target) - if already_loaded + PRELOADER = ActiveRecord::Associations::Preloader.new + + def initialize(*) + super + @already_loaded = owners.first.association(through_reflection.name).loaded? + end + + def preloaded_records + @preloaded_records ||= source_preloaders.flat_map(&:preloaded_records) + end + + def records_by_owner + return @records_by_owner if defined?(@records_by_owner) + source_records_by_owner = source_preloaders.map(&:records_by_owner).reduce(:merge) + through_records_by_owner = through_preloaders.map(&:records_by_owner).reduce(:merge) + + @records_by_owner = owners.each_with_object({}) do |owner, result| + through_records = through_records_by_owner[owner] || [] + + 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 + + records = through_records.flat_map do |record| + source_records_by_owner[record] end - associate_records_to_owner(owner, result) + + records.compact! + records.sort_by! { |rhs| preload_index[rhs] } if scope.order_values.any? + records.uniq! if scope.distinct_value + result[owner] = records end end private + def source_preloaders + @source_preloaders ||= PRELOADER.preload(middle_records, source_reflection.name, scope) + end + + def middle_records + through_preloaders.flat_map(&:preloaded_records) + end + + def through_preloaders + @through_preloaders ||= PRELOADER.preload(owners, through_reflection.name, through_scope) + end + def through_reflection reflection.through_reflection end @@ -49,8 +64,8 @@ module ActiveRecord end def preload_index - @preload_index ||= @preloaded_records.each_with_object({}).with_index do |(id, result), index| - result[id] = index + @preload_index ||= preloaded_records.each_with_object({}).with_index do |(record, result), index| + result[record] = index end end @@ -58,11 +73,15 @@ module ActiveRecord scope = through_reflection.klass.unscoped options = reflection.options + values = reflection_scope.values + if annotations = values[:annotate] + scope.annotate!(*annotations) + end + if options[:source_type] scope.where! reflection.foreign_type => options[:source_type] elsif !reflection_scope.where_clause.empty? scope.where_clause = reflection_scope.where_clause - values = reflection_scope.values if includes = values[:includes] scope.includes!(source_reflection.name => includes) @@ -89,17 +108,7 @@ module ActiveRecord end end - 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 + scope end end end diff --git a/activerecord/lib/active_record/associations/singular_association.rb b/activerecord/lib/active_record/associations/singular_association.rb index 441bd715e4..a92932fa4b 100644 --- a/activerecord/lib/active_record/associations/singular_association.rb +++ b/activerecord/lib/active_record/associations/singular_association.rb @@ -17,9 +17,8 @@ module ActiveRecord replace(record) end - def build(attributes = {}) - record = build_record(attributes) - yield(record) if block_given? + def build(attributes = {}, &block) + record = build_record(attributes, &block) set_new_record(record) record end @@ -27,7 +26,7 @@ module ActiveRecord # Implements the reload reader method, e.g. foo.reload_bar for # Foo.has_one :bar def force_reload_reader - klass.uncached { reload } + reload(true) target end @@ -37,21 +36,7 @@ module ActiveRecord end def find_target - scope = self.scope - return scope.take if skip_statement_cache?(scope) - - conn = klass.connection - sc = reflection.association_scope_cache(conn, owner) do |params| - as = AssociationScope.create { params.bind } - target_scope.merge!(as.scope(self)).limit(1) - end - - binds = AssociationScope.get_bind_values(owner, reflection.chain) - sc.execute(binds, conn) do |record| - set_inverse_instance record - end.first - rescue ::RangeError - nil + super.first end def replace(record) @@ -62,13 +47,8 @@ module ActiveRecord replace(record) end - def _create_record(attributes, raise_error = false) - unless owner.persisted? - raise ActiveRecord::RecordNotSaved, "You cannot call create unless the parent is saved" - end - - record = build_record(attributes) - yield(record) if block_given? + def _create_record(attributes, raise_error = false, &block) + record = build_record(attributes, &block) saved = record.save set_new_record(record) raise RecordInvalid.new(record) if !saved && raise_error diff --git a/activerecord/lib/active_record/associations/through_association.rb b/activerecord/lib/active_record/associations/through_association.rb index 5afb0bc068..15e6565e69 100644 --- a/activerecord/lib/active_record/associations/through_association.rb +++ b/activerecord/lib/active_record/associations/through_association.rb @@ -114,7 +114,7 @@ module ActiveRecord attributes[inverse.foreign_key] = target.id end - super(attributes) + super end end end diff --git a/activerecord/lib/active_record/attribute_assignment.rb b/activerecord/lib/active_record/attribute_assignment.rb index b6f0e18764..929045f29b 100644 --- a/activerecord/lib/active_record/attribute_assignment.rb +++ b/activerecord/lib/active_record/attribute_assignment.rb @@ -45,16 +45,14 @@ module ActiveRecord def execute_callstack_for_multiparameter_attributes(callstack) errors = [] callstack.each do |name, values_with_empty_parameters| - begin - if values_with_empty_parameters.each_value.all?(&:nil?) - values = nil - else - values = values_with_empty_parameters - end - send("#{name}=", values) - rescue => ex - errors << AttributeAssignmentError.new("error on assignment #{values_with_empty_parameters.values.inspect} to #{name} (#{ex.message})", ex, name) + if values_with_empty_parameters.each_value.all?(&:nil?) + values = nil + else + values = values_with_empty_parameters end + send("#{name}=", values) + rescue => ex + errors << AttributeAssignmentError.new("error on assignment #{values_with_empty_parameters.values.inspect} to #{name} (#{ex.message})", ex, name) end unless errors.empty? error_descriptions = errors.map(&:message).join(",") diff --git a/activerecord/lib/active_record/attribute_methods.rb b/activerecord/lib/active_record/attribute_methods.rb index 83b5a5e698..af7e46e649 100644 --- a/activerecord/lib/active_record/attribute_methods.rb +++ b/activerecord/lib/active_record/attribute_methods.rb @@ -22,18 +22,9 @@ module ActiveRecord delegate :column_for_attribute, to: :class end - AttrNames = Module.new { - def self.set_name_cache(name, value) - const_name = "ATTR_#{name}" - unless const_defined? const_name - const_set const_name, value.dup.freeze - end - end - } + RESTRICTED_CLASS_METHODS = %w(private public protected allocate new name parent superclass) - BLACKLISTED_CLASS_METHODS = %w(private public protected allocate new name parent superclass) - - class GeneratedAttributeMethods < Module #:nodoc: + class GeneratedAttributeMethodsBuilder < Module #:nodoc: include Mutex_m end @@ -44,7 +35,8 @@ module ActiveRecord end def initialize_generated_modules # :nodoc: - @generated_attribute_methods = GeneratedAttributeMethods.new + @generated_attribute_methods = const_set(:GeneratedAttributeMethods, GeneratedAttributeMethodsBuilder.new) + private_constant :GeneratedAttributeMethods @attribute_methods_generated = false include @generated_attribute_methods @@ -97,7 +89,7 @@ module ActiveRecord # If ThisClass < ... < SomeSuperClass < ... < Base and SomeSuperClass # defines its own attribute method, then we don't want to overwrite that. defined = method_defined_within?(method_name, superclass, Base) && - ! superclass.instance_method(method_name).owner.is_a?(GeneratedAttributeMethods) + ! superclass.instance_method(method_name).owner.is_a?(GeneratedAttributeMethodsBuilder) defined || super end end @@ -123,7 +115,7 @@ module ActiveRecord # A class method is 'dangerous' if it is already (re)defined by Active Record, but # not by any ancestors. (So 'puts' is not dangerous but 'new' is.) def dangerous_class_method?(method_name) - BLACKLISTED_CLASS_METHODS.include?(method_name.to_s) || class_method_defined_within?(method_name, Base) + RESTRICTED_CLASS_METHODS.include?(method_name.to_s) || class_method_defined_within?(method_name, Base) end def class_method_defined_within?(name, klass, superklass = klass.superclass) # :nodoc: @@ -167,12 +159,14 @@ module ActiveRecord end end - # Regexp whitelist. Matches the following: + # Regexp for column names (with or without a table name prefix). Matches + # the following: # "#{table_name}.#{column_name}" # "#{column_name}" - COLUMN_NAME_WHITELIST = /\A(?:\w+\.)?\w+\z/i + COLUMN_NAME = /\A(?:\w+\.)?\w+\z/i - # Regexp whitelist. Matches the following: + # Regexp for column names with order (with or without a table name + # prefix, with or without various order modifiers). Matches the following: # "#{table_name}.#{column_name}" # "#{table_name}.#{column_name} #{direction}" # "#{table_name}.#{column_name} #{direction} NULLS FIRST" @@ -181,7 +175,7 @@ module ActiveRecord # "#{column_name} #{direction}" # "#{column_name} #{direction} NULLS FIRST" # "#{column_name} NULLS LAST" - COLUMN_NAME_ORDER_WHITELIST = / + COLUMN_NAME_WITH_ORDER = / \A (?:\w+\.)? \w+ @@ -190,12 +184,10 @@ module ActiveRecord \z /ix - def enforce_raw_sql_whitelist(args, whitelist: COLUMN_NAME_WHITELIST) # :nodoc: + def disallow_raw_sql!(args, permit: COLUMN_NAME) # :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) } + Arel.arel_node?(arg) || + arg.to_s.split(/\s*,\s*/).all? { |part| permit.match?(part) } end return if unexpected.none? @@ -270,21 +262,14 @@ module ActiveRecord def respond_to?(name, include_private = false) return false unless super - case name - when :to_partial_path - name = "to_partial_path".freeze - when :to_model - name = "to_model".freeze - else - name = name.to_s - end - # If the result is true then check for the select case. # For queries selecting a subset of columns, return false for unselected columns. # We check defined?(@attributes) not to issue warnings if called on objects that # have been allocated but not yet initialized. - if defined?(@attributes) && self.class.column_names.include?(name) - return has_attribute?(name) + if defined?(@attributes) + if name = self.class.symbol_column_to_string(name.to_sym) + return has_attribute?(name) + end end true @@ -344,15 +329,8 @@ module ActiveRecord # person.attribute_for_inspect(:tag_ids) # # => "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]" def attribute_for_inspect(attr_name) - value = read_attribute(attr_name) - - if value.is_a?(String) && value.length > 50 - "#{value[0, 50]}...".inspect - elsif value.is_a?(Date) || value.is_a?(Time) - %("#{value.to_s(:db)}") - else - value.inspect - end + value = _read_attribute(attr_name) + format_for_inspect(value) end # Returns +true+ if the specified +attribute+ has been set by the user or by a @@ -449,14 +427,6 @@ module ActiveRecord defined?(@attributes) && @attributes.key?(attr_name) end - def attributes_with_values_for_create(attribute_names) - attributes_with_values(attributes_for_create(attribute_names)) - end - - def attributes_with_values_for_update(attribute_names) - attributes_with_values(attributes_for_update(attribute_names)) - end - def attributes_with_values(attribute_names) attribute_names.each_with_object({}) do |name, attrs| attrs[name] = _read_attribute(name) @@ -465,7 +435,8 @@ module ActiveRecord # Filters the primary keys and readonly attributes from the attribute names. def attributes_for_update(attribute_names) - attribute_names.reject do |name| + attribute_names &= self.class.column_names + attribute_names.delete_if do |name| readonly_attribute?(name) end end @@ -473,11 +444,22 @@ module ActiveRecord # Filters out the primary keys, from the attribute names, when the primary # key is to be generated (e.g. the id attribute has no value). def attributes_for_create(attribute_names) - attribute_names.reject do |name| + attribute_names &= self.class.column_names + attribute_names.delete_if do |name| pk_attribute?(name) && id.nil? end end + def format_for_inspect(value) + if value.is_a?(String) && value.length > 50 + "#{value[0, 50]}...".inspect + elsif value.is_a?(Date) || value.is_a?(Time) + %("#{value.to_s(:db)}") + else + value.inspect + end + end + def readonly_attribute?(name) self.class.readonly_attributes.include?(name) end diff --git a/activerecord/lib/active_record/attribute_methods/dirty.rb b/activerecord/lib/active_record/attribute_methods/dirty.rb index 7224f970e0..920a7b2038 100644 --- a/activerecord/lib/active_record/attribute_methods/dirty.rb +++ b/activerecord/lib/active_record/attribute_methods/dirty.rb @@ -16,9 +16,6 @@ module ActiveRecord class_attribute :partial_writes, instance_writer: false, default: true - after_create { changes_applied } - after_update { changes_applied } - # Attribute methods for "changed in last call to save?" attribute_method_affix(prefix: "saved_change_to_", suffix: "?") attribute_method_prefix("saved_change_to_") @@ -32,18 +29,17 @@ 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 end 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 - # after callbacks to determine if the call to save changed a certain - # attribute. + # Did this attribute change when we last saved? + # + # This method is useful in after callbacks to determine if an attribute + # was changed during the save that triggered the callbacks to run. It can + # be invoked as +saved_change_to_name?+ instead of + # <tt>saved_change_to_attribute?("name")</tt>. # # ==== Options # @@ -53,28 +49,29 @@ module ActiveRecord # +to+ When passed, this method will return false unless the value was # changed to the given value def saved_change_to_attribute?(attr_name, **options) - mutations_before_last_save.changed?(attr_name, **options) + mutations_before_last_save.changed?(attr_name.to_s, options) end # Returns the change to an attribute during the last save. If the # attribute was changed, the result will be an array containing the # original value and the saved value. # - # Behaves similarly to +attribute_change+. This method is useful in after - # callbacks, to see the change in an attribute that just occurred - # - # This method can be invoked as +saved_change_to_name+ in instead of - # <tt>saved_change_to_attribute("name")</tt> + # This method is useful in after callbacks, to see the change in an + # attribute during the save that triggered the callbacks to run. It can be + # invoked as +saved_change_to_name+ instead of + # <tt>saved_change_to_attribute("name")</tt>. def saved_change_to_attribute(attr_name) - mutations_before_last_save.change_to_attribute(attr_name) + mutations_before_last_save.change_to_attribute(attr_name.to_s) end # Returns the original value of an attribute before the last save. - # Behaves similarly to +attribute_was+. This method is useful in after - # callbacks to get the original value of an attribute before the save that - # just occurred + # + # This method is useful in after callbacks to get the original value of an + # attribute before the save that triggered the callbacks to run. It can be + # invoked as +name_before_last_save+ instead of + # <tt>attribute_before_last_save("name")</tt>. def attribute_before_last_save(attr_name) - mutations_before_last_save.original_value(attr_name) + mutations_before_last_save.original_value(attr_name.to_s) end # Did the last call to +save+ have any changes to change? @@ -87,58 +84,102 @@ module ActiveRecord mutations_before_last_save.changes end - # Alias for +attribute_changed?+ + # Will this attribute change the next time we save? + # + # This method is useful in validations and before callbacks to determine + # if the next call to +save+ will change a particular attribute. It can be + # invoked as +will_save_change_to_name?+ instead of + # <tt>will_save_change_to_attribute("name")</tt>. + # + # ==== Options + # + # +from+ When passed, this method will return false unless the original + # value is equal to the given option + # + # +to+ When passed, this method will return false unless the value will be + # changed to the given value def will_save_change_to_attribute?(attr_name, **options) - mutations_from_database.changed?(attr_name, **options) + mutations_from_database.changed?(attr_name.to_s, options) end - # Alias for +attribute_change+ + # Returns the change to an attribute that will be persisted during the + # next save. + # + # This method is useful in validations and before callbacks, to see the + # change to an attribute that will occur when the record is saved. It can + # be invoked as +name_change_to_be_saved+ instead of + # <tt>attribute_change_to_be_saved("name")</tt>. + # + # If the attribute will change, the result will be an array containing the + # original value and the new value about to be saved. def attribute_change_to_be_saved(attr_name) - mutations_from_database.change_to_attribute(attr_name) + mutations_from_database.change_to_attribute(attr_name.to_s) end - # Alias for +attribute_was+ + # Returns the value of an attribute in the database, as opposed to the + # in-memory value that will be persisted the next time the record is + # saved. + # + # This method is useful in validations and before callbacks, to see the + # original value of an attribute prior to any changes about to be + # saved. It can be invoked as +name_in_database+ instead of + # <tt>attribute_in_database("name")</tt>. def attribute_in_database(attr_name) - mutations_from_database.original_value(attr_name) + mutations_from_database.original_value(attr_name.to_s) end - # Alias for +changed?+ + # Will the next call to +save+ have any changes to persist? def has_changes_to_save? mutations_from_database.any_changes? end - # Alias for +changes+ + # Returns a hash containing all the changes that will be persisted during + # the next save. def changes_to_save mutations_from_database.changes end - # Alias for +changed+ + # Returns an array of the names of any attributes that will change when + # the record is next saved. def changed_attribute_names_to_save mutations_from_database.changed_attribute_names end - # Alias for +changed_attributes+ + # Returns a hash of the attributes that will change when the record is + # next saved. + # + # The hash keys are the attribute names, and the hash values are the + # original attribute values in the database (as opposed to the in-memory + # values about to be saved). def attributes_in_database - changes_to_save.transform_values(&:first) + mutations_from_database.changed_values end private - def write_attribute_without_type_cast(attr_name, _) - result = super - clear_attribute_change(attr_name) + def write_attribute_without_type_cast(attr_name, value) + name = attr_name.to_s + if self.class.attribute_alias?(name) + name = self.class.attribute_alias(name) + end + result = super(name, value) + clear_attribute_change(name) result end - def _update_record(*) - partial_writes? ? super(keys_for_partial_write) : super + def _update_record(attribute_names = attribute_names_for_partial_writes) + affected_rows = super + changes_applied + affected_rows end - def _create_record(*) - partial_writes? ? super(keys_for_partial_write) : super + def _create_record(attribute_names = attribute_names_for_partial_writes) + id = super + changes_applied + id end - def keys_for_partial_write - changed_attribute_names_to_save & self.class.column_names + def attribute_names_for_partial_writes + partial_writes? ? changed_attribute_names_to_save : attribute_names end end end diff --git a/activerecord/lib/active_record/attribute_methods/primary_key.rb b/activerecord/lib/active_record/attribute_methods/primary_key.rb index 9b267bb7c0..6af5346fa7 100644 --- a/activerecord/lib/active_record/attribute_methods/primary_key.rb +++ b/activerecord/lib/active_record/attribute_methods/primary_key.rb @@ -14,38 +14,39 @@ module ActiveRecord [key] if key end - # Returns the primary key value. + # Returns the primary key column's value. def id sync_with_transaction_state primary_key = self.class.primary_key _read_attribute(primary_key) if primary_key end - # Sets the primary key value. + # Sets the primary key column's value. def id=(value) sync_with_transaction_state primary_key = self.class.primary_key _write_attribute(primary_key, value) if primary_key end - # Queries the primary key value. + # Queries the primary key column's value. def id? sync_with_transaction_state query_attribute(self.class.primary_key) end - # Returns the primary key value before type cast. + # Returns the primary key column's value before type cast. def id_before_type_cast sync_with_transaction_state read_attribute_before_type_cast(self.class.primary_key) end - # Returns the primary key previous value. + # Returns the primary key column's previous value. def id_was sync_with_transaction_state attribute_was(self.class.primary_key) end + # Returns the primary key column's value from the database. def id_in_database sync_with_transaction_state attribute_in_database(self.class.primary_key) diff --git a/activerecord/lib/active_record/attribute_methods/query.rb b/activerecord/lib/active_record/attribute_methods/query.rb index 6757e9b66a..6811f54b10 100644 --- a/activerecord/lib/active_record/attribute_methods/query.rb +++ b/activerecord/lib/active_record/attribute_methods/query.rb @@ -16,8 +16,7 @@ module ActiveRecord when true then true when false, nil then false else - column = self.class.columns_hash[attr_name] - if column.nil? + if !type_for_attribute(attr_name) { false } if Numeric === value || value !~ /[^0-9]/ !value.to_i.zero? else diff --git a/activerecord/lib/active_record/attribute_methods/read.rb b/activerecord/lib/active_record/attribute_methods/read.rb index 14f700b6a9..ffac5313ad 100644 --- a/activerecord/lib/active_record/attribute_methods/read.rb +++ b/activerecord/lib/active_record/attribute_methods/read.rb @@ -8,42 +8,19 @@ module ActiveRecord module ClassMethods # :nodoc: private - # We want to generate the methods via module_eval rather than - # define_method, because define_method is slower on dispatch. - # Evaluating many similar methods may use more memory as the instruction - # sequences are duplicated and cached (in MRI). define_method may - # be slower on dispatch, but if you're careful about the closure - # created, then define_method will consume much less memory. - # - # But sometimes the database might return columns with - # characters that are not allowed in normal method names (like - # 'my_column(omg)'. So to work around this we first define with - # the __temp__ identifier, and then use alias method to rename - # it to what we want. - # - # We are also defining a constant to hold the frozen string of - # the attribute name. Using a constant means that we do not have - # to allocate an object on each call to the attribute method. - # Making it frozen means that it doesn't get duped when used to - # key the @attributes in read_attribute. def define_method_attribute(name) - safe_name = name.unpack1("h*".freeze) - temp_method = "__temp__#{safe_name}" - - ActiveRecord::AttributeMethods::AttrNames.set_name_cache safe_name, name sync_with_transaction_state = "sync_with_transaction_state" if name == primary_key - generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__ + 1 - def #{temp_method} - #{sync_with_transaction_state} - name = ::ActiveRecord::AttributeMethods::AttrNames::ATTR_#{safe_name} - _read_attribute(name) { |n| missing_attribute(n, caller) } - end - STR - - generated_attribute_methods.module_eval do - alias_method name, temp_method - undef_method temp_method + ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method( + generated_attribute_methods, name + ) do |temp_method_name, attr_name_expr| + generated_attribute_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{temp_method_name} + #{sync_with_transaction_state} + name = #{attr_name_expr} + _read_attribute(name) { |n| missing_attribute(n, caller) } + end + RUBY end end end @@ -52,30 +29,21 @@ module ActiveRecord # it has been typecast (for example, "2004-12-12" in a date column is cast # to a date object, like Date.new(2004, 12, 12)). def read_attribute(attr_name, &block) - name = if self.class.attribute_alias?(attr_name) - self.class.attribute_alias(attr_name).to_s - else - attr_name.to_s + name = attr_name.to_s + if self.class.attribute_alias?(name) + name = self.class.attribute_alias(name) end primary_key = self.class.primary_key - name = primary_key if name == "id".freeze && primary_key + name = primary_key if name == "id" && primary_key sync_with_transaction_state if name == primary_key _read_attribute(name, &block) end # This method exists to avoid the expensive primary_key check internally, without # breaking compatibility with the read_attribute API - 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 _read_attribute(attr_name, &block) # :nodoc - @attributes.fetch_value(attr_name.to_s, &block) - end - else - def _read_attribute(attr_name) # :nodoc: - @attributes.fetch_value(attr_name.to_s) { |n| yield n if block_given? } - end + def _read_attribute(attr_name, &block) # :nodoc + @attributes.fetch_value(attr_name.to_s, &block) end alias :attribute :_read_attribute diff --git a/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb b/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb index d2b7817b45..294a3dc32c 100644 --- a/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb +++ b/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb @@ -73,7 +73,7 @@ module ActiveRecord # `skip_time_zone_conversion_for_attributes` would not be picked up. subclass.class_eval do matcher = ->(name, type) { create_time_zone_conversion_attribute?(name, type) } - decorate_matching_attribute_types(matcher, :_time_zone_conversion) do |type| + decorate_matching_attribute_types(matcher, "_time_zone_conversion") do |type| TimeZoneConverter.new(type) end end diff --git a/activerecord/lib/active_record/attribute_methods/write.rb b/activerecord/lib/active_record/attribute_methods/write.rb index c7521422bb..455e67e19b 100644 --- a/activerecord/lib/active_record/attribute_methods/write.rb +++ b/activerecord/lib/active_record/attribute_methods/write.rb @@ -13,19 +13,19 @@ module ActiveRecord private def define_method_attribute=(name) - safe_name = name.unpack1("h*".freeze) - ActiveRecord::AttributeMethods::AttrNames.set_name_cache safe_name, name sync_with_transaction_state = "sync_with_transaction_state" if name == primary_key - generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__ + 1 - def __temp__#{safe_name}=(value) - name = ::ActiveRecord::AttributeMethods::AttrNames::ATTR_#{safe_name} - #{sync_with_transaction_state} - _write_attribute(name, value) - end - alias_method #{(name + '=').inspect}, :__temp__#{safe_name}= - undef_method :__temp__#{safe_name}= - STR + ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method( + generated_attribute_methods, name, writer: true, + ) do |temp_method_name, attr_name_expr| + generated_attribute_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{temp_method_name}(value) + name = #{attr_name_expr} + #{sync_with_transaction_state} + _write_attribute(name, value) + end + RUBY + end end end @@ -33,14 +33,13 @@ module ActiveRecord # specified +value+. Empty strings for Integer and Float columns are # turned into +nil+. def write_attribute(attr_name, value) - name = if self.class.attribute_alias?(attr_name) - self.class.attribute_alias(attr_name).to_s - else - attr_name.to_s + name = attr_name.to_s + if self.class.attribute_alias?(name) + name = self.class.attribute_alias(name) end primary_key = self.class.primary_key - name = primary_key if name == "id".freeze && primary_key + name = primary_key if name == "id" && primary_key sync_with_transaction_state if name == primary_key _write_attribute(name, value) end diff --git a/activerecord/lib/active_record/attributes.rb b/activerecord/lib/active_record/attributes.rb index 35150889d9..7cf421c184 100644 --- a/activerecord/lib/active_record/attributes.rb +++ b/activerecord/lib/active_record/attributes.rb @@ -41,6 +41,9 @@ module ActiveRecord # +range+ (PostgreSQL only) specifies that the type should be a range (see the # examples below). # + # When using a symbol for +cast_type+, extra options are forwarded to the + # constructor of the type object. + # # ==== Examples # # The type detected by Active Record can be overridden. @@ -112,6 +115,16 @@ module ActiveRecord # my_float_range: 1.0..3.5 # } # + # Passing options to the type constructor + # + # # app/models/my_model.rb + # class MyModel < ActiveRecord::Base + # attribute :small_int, :integer, limit: 2 + # end + # + # MyModel.create(small_int: 65537) + # # => Error: 65537 is out of range for the limit of two bytes + # # ==== Creating Custom Types # # Users may also define their own custom types, as long as they respond diff --git a/activerecord/lib/active_record/autosave_association.rb b/activerecord/lib/active_record/autosave_association.rb index a1250c3835..50f29a81a6 100644 --- a/activerecord/lib/active_record/autosave_association.rb +++ b/activerecord/lib/active_record/autosave_association.rb @@ -149,7 +149,7 @@ module ActiveRecord private def define_non_cyclic_method(name, &block) - return if method_defined?(name) + return if instance_methods(false).include?(name) define_method(name) do |*args| result = true; @_already_called ||= {} # Loop prevention for validation of associations @@ -382,10 +382,14 @@ module ActiveRecord if association = association_instance_get(reflection.name) autosave = reflection.options[:autosave] + # By saving the instance variable in a local variable, + # we make the whole callback re-entrant. + new_record_before_save = @new_record_before_save + # reconstruct the scope now that we know the owner's id association.reset_scope - if records = associated_records_to_validate_or_save(association, @new_record_before_save, autosave) + if records = associated_records_to_validate_or_save(association, new_record_before_save, autosave) if autosave records_to_destroy = records.select(&:marked_for_destruction?) records_to_destroy.each { |record| association.destroy(record) } @@ -397,11 +401,16 @@ module ActiveRecord saved = true - if autosave != false && (@new_record_before_save || record.new_record?) + if autosave != false && (new_record_before_save || record.new_record?) if autosave saved = association.insert_record(record, false) - else - association.insert_record(record) unless reflection.nested? + elsif !reflection.nested? + association_saved = association.insert_record(record) + + if reflection.validate? + errors.add(reflection.name) unless association_saved + saved = association_saved + end end elsif autosave saved = record.save(validate: false) @@ -452,10 +461,16 @@ module ActiveRecord # If the record is new or it has changed, returns true. def record_changed?(reflection, record, key) record.new_record? || - (record.has_attribute?(reflection.foreign_key) && record[reflection.foreign_key] != key) || + association_foreign_key_changed?(reflection, record, key) || record.will_save_change_to_attribute?(reflection.foreign_key) end + def association_foreign_key_changed?(reflection, record, key) + return false if reflection.through_reflection? + + record.has_attribute?(reflection.foreign_key) && record[reflection.foreign_key] != key + end + # Saves the associated record if it's new or <tt>:autosave</tt> is enabled. # # In addition, it will destroy the association if it was marked for destruction. diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index 7ab9160265..2af6d09b53 100644 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -22,6 +22,7 @@ require "active_record/explain_subscriber" require "active_record/relation/delegation" require "active_record/attributes" require "active_record/type_caster" +require "active_record/database_configurations" module ActiveRecord #:nodoc: # = Active Record @@ -287,10 +288,9 @@ module ActiveRecord #:nodoc: extend Explain extend Enum extend Delegation::DelegateCache - extend CollectionCacheKey + extend Aggregations::ClassMethods include Core - include DatabaseConfigurations include Persistence include ReadonlyAttributes include ModelSchema @@ -314,7 +314,6 @@ module ActiveRecord #:nodoc: include ActiveModel::SecurePassword include AutosaveAssociation include NestedAttributes - include Aggregations include Transactions include TouchLater include NoTouching diff --git a/activerecord/lib/active_record/callbacks.rb b/activerecord/lib/active_record/callbacks.rb index fd6819d08f..ef5444dfc3 100644 --- a/activerecord/lib/active_record/callbacks.rb +++ b/activerecord/lib/active_record/callbacks.rb @@ -95,7 +95,7 @@ module ActiveRecord # # private # def delete_parents - # self.class.delete_all "parent_id = #{id}" + # self.class.delete_by(parent_id: id) # end # end # @@ -128,7 +128,7 @@ module ActiveRecord # end # end # - # So you specify the object you want messaged on a given callback. When that callback is triggered, the object has + # So you specify the object you want to be messaged on a given callback. When that callback is triggered, the object has # a method by the name of the callback messaged. You can make these callbacks more flexible by passing in other # initialization data such as the name of the attribute to work with: # @@ -318,9 +318,13 @@ module ActiveRecord _run_touch_callbacks { super } end + def increment!(attribute, by = 1, touch: nil) # :nodoc: + touch ? _run_touch_callbacks { super } : super + end + private - def create_or_update(*) + def create_or_update(**) _run_save_callbacks { super } end @@ -328,7 +332,7 @@ module ActiveRecord _run_create_callbacks { super } end - def _update_record(*) + def _update_record _run_update_callbacks { super } end end diff --git a/activerecord/lib/active_record/collection_cache_key.rb b/activerecord/lib/active_record/collection_cache_key.rb deleted file mode 100644 index dfba78614e..0000000000 --- a/activerecord/lib/active_record/collection_cache_key.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module ActiveRecord - module CollectionCacheKey - def collection_cache_key(collection = all, timestamp_column = :updated_at) # :nodoc: - query_signature = ActiveSupport::Digest.hexdigest(collection.to_sql) - key = "#{collection.model_name.cache_key}/query-#{query_signature}" - - if collection.loaded? || collection.distinct_value - size = collection.records.size - if size > 0 - 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) - 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" - - if collection.has_limit_or_offset? - query = collection.select(column) - subquery_alias = "subquery_for_cache_key" - subquery_column = "#{subquery_alias}.#{timestamp_column}" - subquery = query.arel.as(subquery_alias) - arel = Arel::SelectManager.new(subquery).project(select_values % subquery_column) - else - query = collection.unscope(:order) - query.select_values = [select_values % column] - arel = query.arel - end - - result = connection.select_one(arel, nil) - - if result.blank? - size = 0 - timestamp = nil - else - size = result["size"] - timestamp = column_type.deserialize(result["timestamp"]) - end - - end - - if timestamp - "#{key}-#{size}-#{timestamp.utc.to_s(cache_timestamp_format)}" - else - "#{key}-#{size}" - end - end - end -end 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 c730584902..68498b5dc5 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -185,14 +185,16 @@ module ActiveRecord def wait_poll(timeout) @num_waiting += 1 - t0 = Time.now + t0 = Concurrent.monotonic_time elapsed = 0 loop do - @cond.wait(timeout - elapsed) + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + @cond.wait(timeout - elapsed) + end return remove if any? - elapsed = Time.now - t0 + elapsed = Concurrent.monotonic_time - t0 if elapsed >= timeout msg = "could not obtain a connection from the pool within %0.3f seconds (waited %0.3f seconds); all pooled connections were in use" % [timeout, elapsed] @@ -684,13 +686,13 @@ module ActiveRecord end newly_checked_out = [] - timeout_time = Time.now + (@checkout_timeout * 2) + timeout_time = Concurrent.monotonic_time + (@checkout_timeout * 2) @available.with_a_bias_for(Thread.current) do loop do synchronize do return if collected_conns.size == @connections.size && @now_connecting == 0 - remaining_timeout = timeout_time - Time.now + remaining_timeout = timeout_time - Concurrent.monotonic_time remaining_timeout = 0 if remaining_timeout < 0 conn = checkout_for_exclusive_access(remaining_timeout) collected_conns << conn @@ -729,7 +731,7 @@ module ActiveRecord # this block can't be easily moved into attempt_to_checkout_all_existing_connections's # rescue block, because doing so would put it outside of synchronize section, without # being in a critical section thread_report might become inaccurate - msg = "could not obtain ownership of all database connections in #{checkout_timeout} seconds".dup + msg = +"could not obtain ownership of all database connections in #{checkout_timeout} seconds" thread_report = [] @connections.each do |conn| @@ -808,6 +810,7 @@ module ActiveRecord def new_connection Base.send(spec.adapter_method, spec.config).tap do |conn| conn.schema_cache = schema_cache.dup if schema_cache + conn.check_version end end @@ -913,6 +916,16 @@ 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.create_owner_to_pool # :nodoc: + Concurrent::Map.new(initial_capacity: 2) do |h, k| + # Discard the parent's connection pools immediately; we have no need + # of them + discard_unowned_pools(h) + + h[k] = Concurrent::Map.new(initial_capacity: 2) + end + end + def self.unowned_pool_finalizer(pid_map) # :nodoc: lambda do |_| discard_unowned_pools(pid_map) @@ -927,13 +940,7 @@ module ActiveRecord 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 + @owner_to_pool = ConnectionHandler.create_owner_to_pool # Backup finalizer: if the forked child never needed a pool, the above # early discard has not occurred @@ -1004,15 +1011,24 @@ module ActiveRecord # for (not necessarily the current class). def retrieve_connection(spec_name) #:nodoc: pool = retrieve_connection_pool(spec_name) - raise ConnectionNotEstablished, "No connection pool with '#{spec_name}' found." unless pool + + unless pool + # multiple database application + if ActiveRecord::Base.connection_handler != ActiveRecord::Base.default_connection_handler + raise ConnectionNotEstablished, "No connection pool with '#{spec_name}' found for the '#{ActiveRecord::Base.current_role}' role." + else + raise ConnectionNotEstablished, "No connection pool with '#{spec_name}' found." + end + end + pool.connection end # Returns true if a connection that's accessible to this class has # already been opened. def connected?(spec_name) - conn = retrieve_connection_pool(spec_name) - conn && conn.connected? + pool = retrieve_connection_pool(spec_name) + pool && pool.connected? end # Remove the connection for this class. This will close the active diff --git a/activerecord/lib/active_record/connection_adapters/abstract/database_limits.rb b/activerecord/lib/active_record/connection_adapters/abstract/database_limits.rb index 7a9e7add24..75e959045e 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_limits.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_limits.rb @@ -1,22 +1,30 @@ # frozen_string_literal: true +require "active_support/deprecation" + module ActiveRecord module ConnectionAdapters # :nodoc: module DatabaseLimits + def max_identifier_length # :nodoc: + 64 + end + # Returns the maximum length of a table alias. def table_alias_length - 255 + max_identifier_length end # Returns the maximum length of a column name. def column_name_length - 64 + max_identifier_length end + deprecate :column_name_length # Returns the maximum length of a table name. def table_name_length - 64 + max_identifier_length end + deprecate :table_name_length # Returns the maximum allowed length for an index name. This # limit is enforced by \Rails and is less than or equal to @@ -29,23 +37,26 @@ module ActiveRecord # Returns the maximum length of an index name. def index_name_length - 64 + max_identifier_length end # Returns the maximum number of columns per table. def columns_per_table 1024 end + deprecate :columns_per_table # Returns the maximum number of indexes per table. def indexes_per_table 16 end + deprecate :indexes_per_table # Returns the maximum number of columns in a multicolumn index. def columns_per_multicolumn_index 16 end + deprecate :columns_per_multicolumn_index # Returns the maximum number of elements in an IN (x,y,z) clause. # +nil+ means no limit. @@ -57,11 +68,18 @@ module ActiveRecord def sql_query_length 1048575 end + deprecate :sql_query_length # Returns maximum number of joins in a single query. def joins_per_query 256 end + deprecate :joins_per_query + + private + def bind_params_length + 65535 + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb index 08f3e15a4b..ef19538447 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -20,9 +20,22 @@ module ActiveRecord raise "Passing bind parameters with an arel AST is forbidden. " \ "The values must be stored on the AST directly" end - sql, binds = visitor.accept(arel_or_sql_string.ast, collector).value - [sql.freeze, binds || []] + + if prepared_statements + sql, binds = visitor.compile(arel_or_sql_string.ast, collector) + + if binds.length > bind_params_length + unprepared_statement do + sql, binds = to_sql_and_binds(arel_or_sql_string) + visitor.preparable = false + end + end + else + sql = visitor.compile(arel_or_sql_string.ast, collector) + end + [sql.freeze, binds] else + visitor.preparable = false if prepared_statements [arel_or_sql_string.dup.freeze, binds] end end @@ -32,11 +45,11 @@ module ActiveRecord # can be used to query the database repeatedly. def cacheable_query(klass, arel) # :nodoc: if prepared_statements - sql, binds = visitor.accept(arel.ast, collector).value + sql, binds = visitor.compile(arel.ast, collector) query = klass.query(sql) else - collector = PartialQueryCollector.new - parts, binds = visitor.accept(arel.ast, collector).value + collector = klass.partial_query_collector + parts, binds = visitor.compile(arel.ast, collector) query = klass.partial_query(parts) end [query, binds] @@ -46,11 +59,11 @@ module ActiveRecord def select_all(arel, name = nil, binds = [], preparable: nil) arel = arel_from_relation(arel) sql, binds = to_sql_and_binds(arel, binds) - if !prepared_statements || (arel.is_a?(String) && preparable.nil?) - preparable = false - else - preparable = visitor.preparable + + if preparable.nil? + preparable = prepared_statements ? visitor.preparable : false end + if prepared_statements && preparable select_prepared(sql, name, binds) else @@ -93,6 +106,11 @@ module ActiveRecord exec_query(sql, name).rows end + # Determines whether the SQL statement is a write query. + def write_query?(sql) + raise NotImplementedError + end + # Executes the SQL statement in the context of this connection and returns # the raw result from the connection adapter. # Note: depending on your database connector, the result returned by this @@ -113,7 +131,7 @@ module ActiveRecord # +binds+ as the bind substitutes. +name+ is logged along with # the executed +sql+ statement. def exec_insert(sql, name = nil, binds = [], pk = nil, sequence_name = nil) - sql, binds = sql_for_insert(sql, pk, nil, sequence_name, binds) + sql, binds = sql_for_insert(sql, pk, binds) exec_query(sql, name, binds) end @@ -124,11 +142,6 @@ module ActiveRecord exec_query(sql, name, binds) end - # Executes the truncate statement. - def truncate(table_name, name = nil) - raise NotImplementedError - end - # Executes update +sql+ statement in the context of this connection using # +binds+ as the bind substitutes. +name+ is logged along with # the executed +sql+ statement. @@ -163,12 +176,22 @@ module ActiveRecord exec_delete(sql, name, binds) end - # Returns +true+ when the connection adapter supports prepared statement - # caching, otherwise returns +false+ - def supports_statement_cache? # :nodoc: - true + # Executes the truncate statement. + def truncate(table_name, name = nil) + execute(build_truncate_statements(table_name), name) + end + + def truncate_tables(*table_names) # :nodoc: + return if table_names.empty? + + with_multi_statements do + disable_referential_integrity do + Array(build_truncate_statements(*table_names)).each do |sql| + execute_batch(sql, "Truncate Tables") + end + end + end end - deprecate :supports_statement_cache? # Runs the given block in a database transaction, and returns the result # of the block. @@ -259,7 +282,9 @@ module ActiveRecord attr_reader :transaction_manager #:nodoc: - delegate :within_new_transaction, :open_transactions, :current_transaction, :begin_transaction, :commit_transaction, :rollback_transaction, to: :transaction_manager + delegate :within_new_transaction, :open_transactions, :current_transaction, :begin_transaction, + :commit_transaction, :rollback_transaction, :materialize_transactions, + :disable_lazy_transactions!, :enable_lazy_transactions!, to: :transaction_manager def transaction_open? current_transaction.open? @@ -324,68 +349,30 @@ module ActiveRecord # Inserts the given fixture into the table. Overridden in adapters that require # something beyond a simple insert (eg. Oracle). - # Most of adapters should implement `insert_fixtures` that leverages bulk SQL insert. + # Most of adapters should implement `insert_fixtures_set` that leverages bulk SQL insert. # We keep this method to provide fallback # for databases like sqlite that do not support bulk inserts. def insert_fixture(fixture, table_name) - fixture = fixture.stringify_keys - - columns = schema_cache.columns_hash(table_name) - binds = fixture.map do |name, value| - if column = columns[name] - type = lookup_cast_type_from_column(column) - Relation::QueryAttribute.new(name, value, type) - else - raise Fixture::FixtureError, %(table "#{table_name}" has no column named #{name.inspect}.) - end - end - - table = Arel::Table.new(table_name) - - values = binds.map do |bind| - value = with_yaml_fallback(bind.value_for_database) - [table[bind.name], value] - end - - manager = Arel::InsertManager.new - manager.into(table) - manager.insert(values) - execute manager.to_sql, "Fixture Insert" - end - - # Inserts a set of fixtures into the table. Overridden in adapters that require - # something beyond a simple insert (eg. Oracle). - def insert_fixtures(fixtures, table_name) - ActiveSupport::Deprecation.warn(<<-MSG.squish) - `insert_fixtures` is deprecated and will be removed in the next version of Rails. - Consider using `insert_fixtures_set` for performance improvement. - MSG - return if fixtures.empty? - - execute(build_fixture_sql(fixtures, table_name), "Fixtures Insert") + execute(build_fixture_sql(Array.wrap(fixture), table_name), "Fixture Insert") end def insert_fixtures_set(fixture_set, tables_to_delete = []) - fixture_inserts = fixture_set.map do |table_name, fixtures| - next if fixtures.empty? - - build_fixture_sql(fixtures, table_name) - end.compact - - table_deletes = tables_to_delete.map { |table| "DELETE FROM #{quote_table_name table}".dup } - total_sql = Array.wrap(combine_multi_statements(table_deletes + fixture_inserts)) - - disable_referential_integrity do - transaction(requires_new: true) do - total_sql.each do |sql| - execute sql, "Fixtures Load" - yield if block_given? + fixture_inserts = build_fixture_statements(fixture_set) + table_deletes = tables_to_delete.map { |table| "DELETE FROM #{quote_table_name(table)}" } + total_sql = Array(combine_multi_statements(table_deletes + fixture_inserts)) + + with_multi_statements do + disable_referential_integrity do + transaction(requires_new: true) do + total_sql.each do |sql| + execute_batch(sql, "Fixtures Load") + end end end end end - def empty_insert_statement_value + def empty_insert_statement_value(primary_key = nil) "DEFAULT VALUES" end @@ -403,25 +390,33 @@ module ActiveRecord end end - # The default strategy for an UPDATE with joins is to use a subquery. This doesn't work - # on MySQL (even when aliasing the tables), but MySQL allows using JOIN directly in - # an UPDATE statement, so in the MySQL adapters we redefine this to do that. - def join_to_update(update, select, key) # :nodoc: - subselect = subquery_for(key, select) - - update.where key.in(subselect) + # Fixture value is quoted by Arel, however scalar values + # are not quotable. In this case we want to convert + # the column value to YAML. + def with_yaml_fallback(value) # :nodoc: + if value.is_a?(Hash) || value.is_a?(Array) + YAML.dump(value) + else + value + end end - alias join_to_delete join_to_update private + def execute_batch(sql, name = nil) + execute(sql, name) + end + + DEFAULT_INSERT_VALUE = Arel.sql("DEFAULT").freeze + private_constant :DEFAULT_INSERT_VALUE + def default_insert_value(column) - Arel.sql("DEFAULT") + DEFAULT_INSERT_VALUE end def build_fixture_sql(fixtures, table_name) columns = schema_cache.columns_hash(table_name) - values = fixtures.map do |fixture| + values_list = fixtures.map do |fixture| fixture = fixture.stringify_keys unknown_columns = fixture.keys - columns.keys @@ -432,8 +427,7 @@ module ActiveRecord columns.map do |name, column| if fixture.key?(name) type = lookup_cast_type_from_column(column) - bind = Relation::QueryAttribute.new(name, fixture[name], type) - with_yaml_fallback(bind.value_for_database) + with_yaml_fallback(type.serialize(fixture[name])) else default_insert_value(column) end @@ -443,21 +437,45 @@ module ActiveRecord table = Arel::Table.new(table_name) manager = Arel::InsertManager.new manager.into(table) - columns.each_key { |column| manager.columns << table[column] } - manager.values = manager.create_values_list(values) + if values_list.size == 1 + values = values_list.shift + new_values = [] + columns.each_key.with_index { |column, i| + unless values[i].equal?(DEFAULT_INSERT_VALUE) + new_values << values[i] + manager.columns << table[column] + end + } + values_list << new_values + else + columns.each_key { |column| manager.columns << table[column] } + end + + manager.values = manager.create_values_list(values_list) manager.to_sql end - def combine_multi_statements(total_sql) - total_sql.join(";\n") + def build_fixture_statements(fixture_set) + fixture_set.map do |table_name, fixtures| + next if fixtures.empty? + build_fixture_sql(fixtures, table_name) + end.compact end - # Returns a subquery for the given key using the join information. - def subquery_for(key, select) - subselect = select.clone - subselect.projections = [key] - subselect + def build_truncate_statements(*table_names) + truncate_tables = table_names.map do |table_name| + "TRUNCATE TABLE #{quote_table_name(table_name)}" + end + combine_multi_statements(truncate_tables) + end + + def with_multi_statements + yield + end + + def combine_multi_statements(total_sql) + total_sql.join(";\n") end # Returns an ActiveRecord::Result instance. @@ -469,7 +487,7 @@ module ActiveRecord exec_query(sql, name, binds, prepare: true) end - def sql_for_insert(sql, pk, id_value, sequence_name, binds) + def sql_for_insert(sql, pk, binds) [sql, binds] end @@ -489,39 +507,6 @@ module ActiveRecord relation end end - - # Fixture value is quoted by Arel, however scalar values - # are not quotable. In this case we want to convert - # the column value to YAML. - def with_yaml_fallback(value) - if value.is_a?(Hash) || value.is_a?(Array) - YAML.dump(value) - else - value - end - end - - class PartialQueryCollector - def initialize - @parts = [] - @binds = [] - end - - def <<(str) - @parts << str - self - end - - def add_bind(obj) - @binds << obj - @parts << Arel::Nodes::BindParam.new(1) - self - end - - def value - [@parts, @binds] - end - end end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb index 25622e34c8..a7753e3e9c 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb @@ -7,7 +7,8 @@ module ActiveRecord module QueryCache class << self def included(base) #:nodoc: - dirties_query_cache base, :insert, :update, :delete, :rollback_to_savepoint, :rollback_db_transaction + dirties_query_cache base, :insert, :update, :delete, :truncate, :truncate_tables, + :rollback_to_savepoint, :rollback_db_transaction base.set_callback :checkout, :after, :configure_query_cache! base.set_callback :checkin, :after, :disable_query_cache! @@ -17,7 +18,7 @@ module ActiveRecord method_names.each do |method_name| base.class_eval <<-end_code, __FILE__, __LINE__ + 1 def #{method_name}(*) - clear_query_cache if @query_cache_enabled + ActiveRecord::Base.clear_query_caches_for_current_thread if @query_cache_enabled super end end_code @@ -96,6 +97,11 @@ module ActiveRecord if @query_cache_enabled && !locked?(arel) arel = arel_from_relation(arel) sql, binds = to_sql_and_binds(arel, binds) + + if preparable.nil? + preparable = prepared_statements ? visitor.preparable : false + end + cache_sql(sql, name, binds) { super(sql, name, binds, preparable: preparable) } else super @@ -110,12 +116,7 @@ module ActiveRecord if @query_cache[sql].key?(binds) ActiveSupport::Notifications.instrument( "sql.active_record", - sql: sql, - binds: binds, - type_casted_binds: -> { type_casted_binds(binds) }, - name: name, - connection_id: object_id, - cached: true, + cache_notification_info(sql, name, binds) ) @query_cache[sql][binds] else @@ -125,6 +126,19 @@ module ActiveRecord end end + # Database adapters can override this method to + # provide custom cache information. + def cache_notification_info(sql, name, binds) + { + sql: sql, + binds: binds, + type_casted_binds: -> { type_casted_binds(binds) }, + name: name, + connection_id: object_id, + cached: true + } + end + # If arel is locked this is a SELECT ... FOR UPDATE or somesuch. Such # queries should not be cached. def locked?(arel) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb b/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb index aec5fa6ba1..2877530917 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb @@ -60,7 +60,7 @@ module ActiveRecord # Quotes a string, escaping any ' (single quote) and \ (backslash) # characters. def quote_string(s) - s.gsub('\\'.freeze, '\&\&'.freeze).gsub("'".freeze, "''".freeze) # ' (for ruby-mode) + s.gsub('\\', '\&\&').gsub("'", "''") # ' (for ruby-mode) end # Quotes the column name. Defaults to no quoting. @@ -95,7 +95,7 @@ module ActiveRecord end def quoted_true - "TRUE".freeze + "TRUE" end def unquoted_true @@ -103,7 +103,7 @@ module ActiveRecord end def quoted_false - "FALSE".freeze + "FALSE" end def unquoted_false @@ -130,6 +130,7 @@ module ActiveRecord end def quoted_time(value) # :nodoc: + value = value.change(year: 2000, month: 1, day: 1) quoted_date(value).sub(/\A\d\d\d\d-\d\d-\d\d /, "") end @@ -137,15 +138,19 @@ module ActiveRecord "'#{quote_string(value.to_s)}'" end - def type_casted_binds(binds) # :nodoc: - if binds.first.is_a?(Array) - binds.map { |column, value| type_cast(value, column) } - else - binds.map { |attr| type_cast(attr.value_for_database) } - end + def sanitize_as_sql_comment(value) # :nodoc: + value.to_s.gsub(%r{ (/ (?: | \g<1>) \*) \+? \s* | \s* (\* (?: | \g<2>) /) }x, "") end private + def type_casted_binds(binds) + if binds.first.is_a?(Array) + binds.map { |column, value| type_cast(value, column) } + else + binds.map { |attr| type_cast(attr.value_for_database) } + end + end + def lookup_cast_type(sql_type) type_map.lookup(sql_type) end @@ -156,13 +161,9 @@ module ActiveRecord end end - def types_which_need_no_typecasting - [nil, Numeric, String] - end - def _quote(value) case value - when String, ActiveSupport::Multibyte::Chars + when String, Symbol, ActiveSupport::Multibyte::Chars "'#{quote_string(value.to_s)}'" when true then quoted_true when false then quoted_false @@ -173,7 +174,6 @@ module ActiveRecord when Type::Binary::Data then quoted_binary(value) when Type::Time::Value then "'#{quoted_time(value)}'" when Date, Time then "'#{quoted_date(value)}'" - when Symbol then "'#{quote_string(value.to_s)}'" when Class then "'#{value}'" else raise TypeError, "can't quote #{value.class.name}" end @@ -187,10 +187,9 @@ module ActiveRecord when false then unquoted_false # BigDecimals need to be put in a non-normalized form and quoted. when BigDecimal then value.to_s("F") + when nil, Numeric, String then value when Type::Time::Value then quoted_time(value) when Date, Time then quoted_date(value) - when *types_which_need_no_typecasting - value else raise TypeError end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb index 529c9d8ca6..7d20825a75 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb @@ -15,13 +15,13 @@ module ActiveRecord end delegate :quote_column_name, :quote_table_name, :quote_default_expression, :type_to_sql, - :options_include_default?, :supports_indexes_in_create?, :supports_foreign_keys_in_create?, :foreign_key_options, + :options_include_default?, :supports_indexes_in_create?, :supports_foreign_keys?, :foreign_key_options, to: :@conn, private: true private def visit_AlterTable(o) - sql = "ALTER TABLE #{quote_table_name(o.name)} ".dup + sql = +"ALTER TABLE #{quote_table_name(o.name)} " sql << o.adds.map { |col| accept col }.join(" ") sql << o.foreign_key_adds.map { |fk| visit_AddForeignKey fk }.join(" ") sql << o.foreign_key_drops.map { |fk| visit_DropForeignKey fk }.join(" ") @@ -29,17 +29,19 @@ module ActiveRecord def visit_ColumnDefinition(o) o.sql_type = type_to_sql(o.type, o.options) - column_sql = "#{quote_column_name(o.name)} #{o.sql_type}".dup + column_sql = +"#{quote_column_name(o.name)} #{o.sql_type}" add_column_options!(column_sql, column_options(o)) unless o.type == :primary_key column_sql end def visit_AddColumnDefinition(o) - "ADD #{accept(o.column)}".dup + +"ADD #{accept(o.column)}" end def visit_TableDefinition(o) - create_sql = "CREATE#{' TEMPORARY' if o.temporary} TABLE #{quote_table_name(o.name)} ".dup + create_sql = +"CREATE#{table_modifier_in_create(o)} TABLE " + create_sql << "IF NOT EXISTS " if o.if_not_exists + create_sql << "#{quote_table_name(o.name)} " statements = o.columns.map { |c| accept c } statements << accept(o.primary_keys) if o.primary_keys @@ -48,7 +50,7 @@ module ActiveRecord statements.concat(o.indexes.map { |column_name, options| index_in_create(o.name, column_name, options) }) end - if supports_foreign_keys_in_create? + if supports_foreign_keys? statements.concat(o.foreign_keys.map { |to_table, options| foreign_key_in_create(o.name, to_table, options) }) end @@ -119,7 +121,15 @@ module ActiveRecord sql end + # Returns any SQL string to go between CREATE and TABLE. May be nil. + def table_modifier_in_create(o) + " TEMPORARY" if o.temporary + end + def foreign_key_in_create(from_table, to_table, options) + prefix = ActiveRecord::Base.table_name_prefix + suffix = ActiveRecord::Base.table_name_suffix + to_table = "#{prefix}#{to_table}#{suffix}" options = foreign_key_options(from_table, to_table, options) accept ForeignKeyDefinition.new(from_table, to_table, options) end 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 6a498b353c..688eea75e8 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb @@ -102,16 +102,12 @@ module ActiveRecord alias validated? validate? def export_name_on_schema_dump? - name !~ ActiveRecord::SchemaDumper.fk_ignore_pattern + !ActiveRecord::SchemaDumper.fk_ignore_pattern.match?(name) if name end - def defined_for?(to_table_ord = nil, to_table: nil, **options) - if to_table_ord - self.to_table == to_table_ord.to_s - else - (to_table.nil? || to_table.to_s == self.to_table) && - options.all? { |k, v| self.options[k].to_s == v.to_s } - end + def defined_for?(to_table: nil, **options) + (to_table.nil? || to_table.to_s == self.to_table) && + options.all? { |k, v| self.options[k].to_s == v.to_s } end private @@ -198,41 +194,44 @@ module ActiveRecord end module ColumnMethods + extend ActiveSupport::Concern + # Appends a primary key definition to the table definition. # Can be called multiple times, but this is probably not a good idea. def primary_key(name, type = :primary_key, **options) column(name, type, options.merge(primary_key: true)) end + ## + # :method: column + # :call-seq: column(name, type, **options) + # # Appends a column or columns of a specified type. # # t.string(:goat) # t.string(:goat, :sheep) # # See TableDefinition#column - [ - :bigint, - :binary, - :boolean, - :date, - :datetime, - :decimal, - :float, - :integer, - :json, - :string, - :text, - :time, - :timestamp, - :virtual, - ].each do |column_type| - module_eval <<-CODE, __FILE__, __LINE__ + 1 - def #{column_type}(*args, **options) - args.each { |name| column(name, :#{column_type}, options) } + + included do + define_column_methods :bigint, :binary, :boolean, :date, :datetime, :decimal, + :float, :integer, :json, :string, :text, :time, :timestamp, :virtual + + alias :numeric :decimal + end + + class_methods do + private def define_column_methods(*column_types) # :nodoc: + column_types.each do |column_type| + module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{column_type}(*names, **options) + raise ArgumentError, "Missing column name(s) for #{column_type}" if names.empty? + names.each { |name| column(name, :#{column_type}, options) } + end + RUBY end - CODE + end end - alias_method :numeric, :decimal end # Represents the schema of an SQL table in an abstract way. This class @@ -256,15 +255,25 @@ module ActiveRecord class TableDefinition include ColumnMethods - attr_accessor :indexes - attr_reader :name, :temporary, :options, :as, :foreign_keys, :comment + attr_reader :name, :temporary, :if_not_exists, :options, :as, :comment, :indexes, :foreign_keys - def initialize(name, temporary = false, options = nil, as = nil, comment: nil) + def initialize( + conn, + name, + temporary: false, + if_not_exists: false, + options: nil, + as: nil, + comment: nil, + ** + ) + @conn = conn @columns_hash = {} @indexes = [] @foreign_keys = [] @primary_keys = nil @temporary = temporary + @if_not_exists = if_not_exists @options = options @as = as @name = name @@ -348,16 +357,20 @@ module ActiveRecord # # create_table :taggings do |t| # t.references :tag, index: { name: 'index_taggings_on_tag_id' } - # t.references :tagger, polymorphic: true, index: true - # t.references :taggable, polymorphic: { default: 'Photo' } + # t.references :tagger, polymorphic: true + # t.references :taggable, polymorphic: { default: 'Photo' }, index: false # end - def column(name, type, options = {}) + def column(name, type, **options) name = name.to_s type = type.to_sym if type options = options.dup - if @columns_hash[name] && @columns_hash[name].primary_key? - raise ArgumentError, "you can't redefine the primary key column '#{name}'. To define a custom primary key, pass { id: false } to create_table." + if @columns_hash[name] + if @columns_hash[name].primary_key? + raise ArgumentError, "you can't redefine the primary key column '#{name}'. To define a custom primary key, pass { id: false } to create_table." + else + raise ArgumentError, "you can't define an already defined column '#{name}'." + end end index_options = options.delete(:index) @@ -381,10 +394,7 @@ module ActiveRecord end def foreign_key(table_name, options = {}) # :nodoc: - table_name_prefix = ActiveRecord::Base.table_name_prefix - table_name_suffix = ActiveRecord::Base.table_name_suffix - table_name = "#{table_name_prefix}#{table_name}#{table_name_suffix}" - foreign_keys.push([table_name, options]) + foreign_keys << [table_name, options] end # Appends <tt>:datetime</tt> columns <tt>:created_at</tt> and @@ -394,6 +404,10 @@ module ActiveRecord def timestamps(**options) options[:null] = false if options[:null].nil? + if !options.key?(:precision) && @conn.supports_datetime_with_precision? + options[:precision] = 6 + end + column(:created_at, :datetime, options) column(:updated_at, :datetime, options) end @@ -402,6 +416,7 @@ module ActiveRecord # # t.references(:user) # t.belongs_to(:supplier, foreign_key: true) + # t.belongs_to(:supplier, foreign_key: true, type: :integer) # # See {connection.add_reference}[rdoc-ref:SchemaStatements#add_reference] for details of the options you can use. def references(*args, **options) @@ -497,7 +512,11 @@ module ActiveRecord # t.date # t.binary # t.boolean + # t.foreign_key + # t.json + # t.virtual # t.remove + # t.remove_foreign_key # t.remove_references # t.remove_belongs_to # t.remove_index @@ -519,8 +538,10 @@ module ActiveRecord # t.column(:name, :string) # # See TableDefinition#column for details of the options you can use. - def column(column_name, type, options = {}) + def column(column_name, type, **options) + index_options = options.delete(:index) @base.add_column(name, column_name, type, options) + index(column_name, index_options.is_a?(Hash) ? index_options : {}) if index_options end # Checks to see if a column exists. @@ -659,21 +680,32 @@ module ActiveRecord end alias :remove_belongs_to :remove_references - # Adds a foreign key. + # Adds a foreign key to the table using a supplied table name. # - # t.foreign_key(:authors) + # t.foreign_key(:authors) + # t.foreign_key(:authors, column: :author_id, primary_key: "id") # # See {connection.add_foreign_key}[rdoc-ref:SchemaStatements#add_foreign_key] - def foreign_key(*args) # :nodoc: + def foreign_key(*args) @base.add_foreign_key(name, *args) end + # Removes the given foreign key from the table. + # + # t.remove_foreign_key(:authors) + # t.remove_foreign_key(column: :author_id) + # + # See {connection.remove_foreign_key}[rdoc-ref:SchemaStatements#remove_foreign_key] + def remove_foreign_key(*args) + @base.remove_foreign_key(name, *args) + end + # Checks to see if a foreign key exists. # - # t.foreign_key(:authors) unless t.foreign_key_exists?(:authors) + # t.foreign_key(:authors) unless t.foreign_key_exists?(:authors) # # See {connection.foreign_key_exists?}[rdoc-ref:SchemaStatements#foreign_key_exists?] - def foreign_key_exists?(*args) # :nodoc: + def foreign_key_exists?(*args) @base.foreign_key_exists?(name, *args) end end 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 ac73337aef..4840307094 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -2,6 +2,7 @@ require "active_record/migration/join_table" require "active_support/core_ext/string/access" +require "active_support/deprecation" require "digest/sha2" module ActiveRecord @@ -129,11 +130,11 @@ module ActiveRecord # column_exists?(:suppliers, :name, :string, null: false) # column_exists?(:suppliers, :tax, :decimal, precision: 8, scale: 2) # - def column_exists?(table_name, column_name, type = nil, options = {}) + def column_exists?(table_name, column_name, type = nil, **options) column_name = column_name.to_s checks = [] checks << lambda { |c| c.name == column_name } - checks << lambda { |c| c.type == type } if type + checks << lambda { |c| c.type == type.to_sym rescue nil } if type column_options_keys.each do |attr| checks << lambda { |c| c.send(attr) == options[attr] } if options.key?(attr) end @@ -205,19 +206,22 @@ module ActiveRecord # Set to true to drop the table before creating it. # Set to +:cascade+ to drop dependent objects as well. # Defaults to false. + # [<tt>:if_not_exists</tt>] + # Set to true to avoid raising an error when the table already exists. + # Defaults to false. # [<tt>:as</tt>] # SQL to use to generate the table. When this option is used, the block is # ignored, as are the <tt>:id</tt> and <tt>:primary_key</tt> options. # # ====== Add a backend specific option to the generated SQL (MySQL) # - # create_table(:suppliers, options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8') + # create_table(:suppliers, options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4') # # generates: # # CREATE TABLE suppliers ( # id bigint auto_increment PRIMARY KEY - # ) ENGINE=InnoDB DEFAULT CHARSET=utf8 + # ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 # # ====== Rename the primary key column # @@ -287,8 +291,8 @@ module ActiveRecord # SELECT * FROM orders INNER JOIN line_items ON order_id=orders.id # # See also TableDefinition#column for details on how to create columns. - def create_table(table_name, comment: nil, **options) - td = create_table_definition table_name, options[:temporary], options[:options], options[:as], comment: comment + def create_table(table_name, **options) + td = create_table_definition(table_name, options) if options[:id] != false && !options[:as] pk = options.fetch(:primary_key) do @@ -305,8 +309,7 @@ module ActiveRecord yield td if block_given? if options[:force] - drop_opts = { if_exists: true }.merge(**options) - drop_table(table_name, drop_opts) + drop_table(table_name, options.merge(if_exists: true)) end result = execute schema_creation.accept td @@ -318,7 +321,9 @@ module ActiveRecord end if supports_comments? && !supports_comments_in_create? - change_table_comment(table_name, comment) if comment.present? + if table_comment = options[:comment].presence + change_table_comment(table_name, table_comment) + end td.columns.each do |column| change_column_comment(table_name, column.name, column.comment) if column.comment.present? @@ -523,6 +528,9 @@ module ActiveRecord # Specifies the precision for the <tt>:decimal</tt> and <tt>:numeric</tt> columns. # * <tt>:scale</tt> - # Specifies the scale for the <tt>:decimal</tt> and <tt>:numeric</tt> columns. + # * <tt>:collation</tt> - + # Specifies the collation for a <tt>:string</tt> or <tt>:text</tt> column. If not specified, the + # column will have the same collation as the table. # * <tt>:comment</tt> - # Specifies the comment for the column. This option is ignored by some backends. # @@ -576,7 +584,7 @@ module ActiveRecord # # Defines a column with a database-specific type. # add_column(:shapes, :triangle, 'polygon') # # ALTER TABLE "shapes" ADD "triangle" polygon - def add_column(table_name, column_name, type, options = {}) + def add_column(table_name, column_name, type, **options) at = create_alter_table table_name at.add_column(column_name, type, options) execute schema_creation.accept at @@ -600,6 +608,7 @@ module ActiveRecord # The +type+ and +options+ parameters will be ignored if present. It can be helpful # 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. + # Indexes on the column are automatically removed. def remove_column(table_name, column_name, type = nil, options = {}) execute "ALTER TABLE #{quote_table_name(table_name)} #{remove_column_for_alter(table_name, column_name, type, options)}" end @@ -742,22 +751,13 @@ module ActiveRecord # ====== 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 + # # 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 + # # 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 + # # 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 # @@ -852,17 +852,17 @@ module ActiveRecord # [<tt>:null</tt>] # Whether the column allows nulls. Defaults to true. # - # ====== Create a user_id bigint column + # ====== Create a user_id bigint column without an index # - # add_reference(:products, :user) + # add_reference(:products, :user, index: false) # # ====== Create a user_id string column # # add_reference(:products, :user, type: :string) # - # ====== Create supplier_id, supplier_type columns and appropriate index + # ====== Create supplier_id, supplier_type columns # - # add_reference(:products, :supplier, polymorphic: true, index: true) + # add_reference(:products, :supplier, polymorphic: true) # # ====== Create a supplier_id column with a unique index # @@ -890,7 +890,7 @@ module ActiveRecord # # ====== Remove the reference # - # remove_reference(:products, :user, index: true) + # remove_reference(:products, :user, index: false) # # ====== Remove polymorphic reference # @@ -898,7 +898,7 @@ module ActiveRecord # # ====== Remove the reference with a foreign key # - # remove_reference(:products, :user, index: true, foreign_key: true) + # remove_reference(:products, :user, foreign_key: true) # def remove_reference(table_name, ref_name, foreign_key: false, polymorphic: false, **options) if foreign_key @@ -966,7 +966,7 @@ module ActiveRecord # [<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+. + # (PostgreSQL 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? @@ -990,15 +990,22 @@ module ActiveRecord # # remove_foreign_key :accounts, column: :owner_id # + # Removes the foreign key on +accounts.owner_id+. + # + # remove_foreign_key :accounts, to_table: :owners + # # Removes the foreign key named +special_fk_name+ on the +accounts+ table. # # remove_foreign_key :accounts, name: :special_fk_name # - # The +options+ hash accepts the same keys as SchemaStatements#add_foreign_key. - def remove_foreign_key(from_table, options_or_to_table = {}) + # The +options+ hash accepts the same keys as SchemaStatements#add_foreign_key + # with an addition of + # [<tt>:to_table</tt>] + # The name of the table that contains the referenced primary key. + def remove_foreign_key(from_table, to_table = nil, **options) return unless supports_foreign_keys? - fk_name_to_delete = foreign_key_for!(from_table, options_or_to_table).name + fk_name_to_delete = foreign_key_for!(from_table, to_table: to_table, **options).name at = create_alter_table from_table at.drop_foreign_key fk_name_to_delete @@ -1017,14 +1024,12 @@ module ActiveRecord # # Checks to see if a foreign key with a custom name exists. # foreign_key_exists?(:accounts, name: "special_fk_name") # - def foreign_key_exists?(from_table, options_or_to_table = {}) - foreign_key_for(from_table, options_or_to_table).present? + def foreign_key_exists?(from_table, to_table = nil, **options) + foreign_key_for(from_table, to_table: to_table, **options).present? end def foreign_key_column_for(table_name) # :nodoc: - prefix = Base.table_name_prefix - suffix = Base.table_name_suffix - name = table_name.to_s =~ /#{prefix}(.+)#{suffix}/ ? $1 : table_name.to_s + name = strip_table_name_prefix_and_suffix(table_name) "#{name.singularize}_id" end @@ -1044,15 +1049,18 @@ module ActiveRecord { primary_key: true } end - def assume_migrated_upto_version(version, migrations_paths) - migrations_paths = Array(migrations_paths) + def assume_migrated_upto_version(version, migrations_paths = nil) + unless migrations_paths.nil? + ActiveSupport::Deprecation.warn(<<~MSG.squish) + Passing migrations_paths to #assume_migrated_upto_version is deprecated and will be removed in Rails 6.1. + MSG + end + version = version.to_i sm_table = quote_table_name(ActiveRecord::SchemaMigration.table_name) - migrated = ActiveRecord::SchemaMigration.all_versions.map(&:to_i) - versions = migration_context.migration_files.map do |file| - migration_context.parse_migration_filename(file).first.to_i - end + migrated = migration_context.get_all_versions + versions = migration_context.migrations.map(&:version) unless migrated.include?(version) execute "INSERT INTO #{sm_table} (version) VALUES (#{quote(version)})" @@ -1063,13 +1071,7 @@ module ActiveRecord if (duplicate = inserting.detect { |v| inserting.count(v) > 1 }) raise "Duplicate migration #{duplicate}. Please renumber your migrations to resolve the conflict." end - if supports_multi_insert? - execute insert_versions_sql(inserting) - else - inserting.each do |v| - execute insert_versions_sql(v) - end - end + execute insert_versions_sql(inserting) end end @@ -1095,7 +1097,7 @@ module ActiveRecord if (0..6) === precision column_type_sql << "(#{precision})" else - raise(ActiveRecordError, "No #{native[:name]} type has precision of #{precision}. The allowed range of precision is from 0 to 6") + raise ArgumentError, "No #{native[:name]} type has precision of #{precision}. The allowed range of precision is from 0 to 6" end elsif (type != :primary_key) && (limit ||= native.is_a?(Hash) && native[:limit]) column_type_sql << "(#{limit})" @@ -1125,6 +1127,10 @@ module ActiveRecord def add_timestamps(table_name, options = {}) options[:null] = false if options[:null].nil? + if !options.key?(:precision) && supports_datetime_with_precision? + options[:precision] = 6 + end + add_column table_name, :created_at, :datetime, options add_column table_name, :updated_at, :datetime, options end @@ -1286,7 +1292,7 @@ module ActiveRecord end def create_table_definition(*args) - TableDefinition.new(*args) + TableDefinition.new(self, *args) end def create_alter_table(name) @@ -1320,6 +1326,12 @@ module ActiveRecord { column: column_names } end + def strip_table_name_prefix_and_suffix(table_name) + prefix = Base.table_name_prefix + suffix = Base.table_name_suffix + table_name.to_s =~ /#{prefix}(.+)#{suffix}/ ? $1 : table_name.to_s + end + def foreign_key_name(table_name, options) options.fetch(:name) do identifier = "#{table_name}_#{options.fetch(:column)}_fk" @@ -1329,14 +1341,14 @@ module ActiveRecord end end - def foreign_key_for(from_table, options_or_to_table = {}) + def foreign_key_for(from_table, **options) return unless supports_foreign_keys? - foreign_keys(from_table).detect { |fk| fk.defined_for? options_or_to_table } + foreign_keys(from_table).detect { |fk| fk.defined_for?(options) } end - def foreign_key_for!(from_table, options_or_to_table = {}) - foreign_key_for(from_table, options_or_to_table) || \ - raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{options_or_to_table}") + def foreign_key_for!(from_table, to_table: nil, **options) + foreign_key_for(from_table, to_table: to_table, **options) || + raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{to_table || options}") end def extract_foreign_key_action(specifier) @@ -1385,7 +1397,7 @@ module ActiveRecord sm_table = quote_table_name(ActiveRecord::SchemaMigration.table_name) if versions.is_a?(Array) - sql = "INSERT INTO #{sm_table} (version) VALUES\n".dup + sql = +"INSERT INTO #{sm_table} (version) VALUES\n" sql << versions.map { |v| "(#{quote(v)})" }.join(",\n") sql << ";\n\n" sql diff --git a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb index d9ac8db6a8..c9e84e48cc 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb @@ -17,11 +17,19 @@ module ActiveRecord end def committed? - @state == :committed + @state == :committed || @state == :fully_committed + end + + def fully_committed? + @state == :fully_committed end def rolledback? - @state == :rolledback + @state == :rolledback || @state == :fully_rolledback + end + + def fully_rolledback? + @state == :fully_rolledback end def fully_completed? @@ -32,33 +40,24 @@ module ActiveRecord committed? || rolledback? end - def set_state(state) - ActiveSupport::Deprecation.warn(<<-MSG.squish) - The set_state method is deprecated and will be removed in - Rails 6.0. Please use rollback! or commit! to set transaction - state directly. - MSG - case state - when :rolledback - rollback! - when :committed - commit! - when nil - nullify! - else - raise ArgumentError, "Invalid transaction state: #{state}" - end - end - def rollback! @children.each { |c| c.rollback! } @state = :rolledback end + def full_rollback! + @children.each { |c| c.rollback! } + @state = :fully_rolledback + end + def commit! @state = :committed end + def full_commit! + @state = :fully_committed + end + def nullify! @state = nil end @@ -74,13 +73,14 @@ module ActiveRecord end class Transaction #:nodoc: - attr_reader :connection, :state, :records, :savepoint_name - attr_writer :joinable + attr_reader :connection, :state, :records, :savepoint_name, :isolation_level def initialize(connection, options, run_commit_callbacks: false) @connection = connection @state = TransactionState.new @records = [] + @isolation_level = options[:isolation] + @materialized = false @joinable = options.fetch(:joinable, true) @run_commit_callbacks = run_commit_callbacks end @@ -89,8 +89,12 @@ module ActiveRecord records << record end - def rollback - @state.rollback! + def materialize! + @materialized = true + end + + def materialized? + @materialized end def rollback_records @@ -104,10 +108,6 @@ module ActiveRecord end end - def commit - @state.commit! - end - def before_commit_records records.uniq.each(&:before_committed!) if @run_commit_callbacks end @@ -119,7 +119,7 @@ module ActiveRecord record.committed! else # if not running callbacks, only adds the record to the parent transaction - record.add_to_transaction + connection.add_transaction_record(record) end end ensure @@ -133,48 +133,55 @@ module ActiveRecord end class SavepointTransaction < Transaction - def initialize(connection, savepoint_name, parent_transaction, options, *args) - super(connection, options, *args) + def initialize(connection, savepoint_name, parent_transaction, *args) + super(connection, *args) parent_transaction.state.add_child(@state) - if options[:isolation] + if isolation_level raise ActiveRecord::TransactionIsolationError, "cannot set transaction isolation in a nested transaction" end - connection.create_savepoint(@savepoint_name = savepoint_name) + + @savepoint_name = savepoint_name end - def rollback - connection.rollback_to_savepoint(savepoint_name) + def materialize! + connection.create_savepoint(savepoint_name) super end + def rollback + connection.rollback_to_savepoint(savepoint_name) if materialized? + @state.rollback! + end + def commit - connection.release_savepoint(savepoint_name) - super + connection.release_savepoint(savepoint_name) if materialized? + @state.commit! end def full_rollback?; false; end end class RealTransaction < Transaction - def initialize(connection, options, *args) - super - if options[:isolation] - connection.begin_isolated_db_transaction(options[:isolation]) + def materialize! + if isolation_level + connection.begin_isolated_db_transaction(isolation_level) else connection.begin_db_transaction end + + super end def rollback - connection.rollback_db_transaction - super + connection.rollback_db_transaction if materialized? + @state.full_rollback! end def commit - connection.commit_db_transaction - super + connection.commit_db_transaction if materialized? + @state.full_commit! end end @@ -182,6 +189,9 @@ module ActiveRecord def initialize(connection) @stack = [] @connection = connection + @has_unmaterialized_transactions = false + @materializing_transactions = false + @lazy_transactions_enabled = true end def begin_transaction(options = {}) @@ -195,11 +205,44 @@ module ActiveRecord run_commit_callbacks: run_commit_callbacks) end + if @connection.supports_lazy_transactions? && lazy_transactions_enabled? && options[:_lazy] != false + @has_unmaterialized_transactions = true + else + transaction.materialize! + end @stack.push(transaction) transaction end end + def disable_lazy_transactions! + materialize_transactions + @lazy_transactions_enabled = false + end + + def enable_lazy_transactions! + @lazy_transactions_enabled = true + end + + def lazy_transactions_enabled? + @lazy_transactions_enabled + end + + def materialize_transactions + return if @materializing_transactions + return unless @has_unmaterialized_transactions + + @connection.lock.synchronize do + begin + @materializing_transactions = true + @stack.each { |t| t.materialize! unless t.materialized? } + ensure + @materializing_transactions = false + end + @has_unmaterialized_transactions = false + end + end + def commit_transaction @connection.lock.synchronize do transaction = @stack.last @@ -225,26 +268,24 @@ module ActiveRecord def within_new_transaction(options = {}) @connection.lock.synchronize do - begin - transaction = begin_transaction options - yield - rescue Exception => error - if transaction + transaction = begin_transaction options + yield + rescue Exception => error + if transaction + rollback_transaction + after_failure_actions(transaction, error) + end + raise + ensure + if !error && transaction + if Thread.current.status == "aborting" rollback_transaction - after_failure_actions(transaction, error) - end - raise - ensure - unless error - if Thread.current.status == "aborting" - rollback_transaction if transaction - else - begin - commit_transaction if transaction - rescue Exception - rollback_transaction(transaction) unless transaction.state.completed? - raise - end + else + begin + commit_transaction + rescue Exception + rollback_transaction(transaction) unless transaction.state.completed? + raise end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 559f068c39..200184c2f9 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -6,6 +6,7 @@ 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 "active_support/deprecation" require "arel/collectors/bind" require "arel/collectors/composite" require "arel/collectors/sql_string" @@ -65,7 +66,7 @@ module ActiveRecord # Most of the methods in the adapter are useful during migrations. Most # notably, the instance methods provided by SchemaStatements are very useful. class AbstractAdapter - ADAPTER_NAME = "Abstract".freeze + ADAPTER_NAME = "Abstract" include ActiveSupport::Callbacks define_callbacks :checkout, :checkin @@ -76,12 +77,16 @@ module ActiveRecord SIMPLE_INT = /\A\d+\z/ - attr_accessor :visitor, :pool - attr_reader :schema_cache, :owner, :logger, :prepared_statements, :lock + attr_accessor :pool + attr_reader :schema_cache, :visitor, :owner, :logger, :lock, :prepared_statements, :prevent_writes alias :in_use? :owner + set_callback :checkin, :after, :enable_lazy_transactions! + def self.type_cast_config_to_integer(config) - if config =~ SIMPLE_INT + if config.is_a?(Integer) + config + elsif SIMPLE_INT.match?(config) config.to_i else config @@ -96,6 +101,11 @@ module ActiveRecord end end + def self.build_read_query_regexp(*parts) # :nodoc: + parts = parts.map { |part| /\A[\(\s]*#{part}/i } + Regexp.union(*parts) + end + def initialize(connection, logger = nil, config = {}) # :nodoc: super() @@ -108,7 +118,9 @@ module ActiveRecord @idle_since = Concurrent.monotonic_time @schema_cache = SchemaCache.new self @quoted_column_names, @quoted_table_names = {}, {} + @prevent_writes = false @visitor = arel_visitor + @statements = build_statement_pool @lock = ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new if self.class.type_cast_config_to_boolean(config.fetch(:prepared_statements) { true }) @@ -117,6 +129,34 @@ module ActiveRecord else @prepared_statements = false end + + @advisory_locks_enabled = self.class.type_cast_config_to_boolean( + config.fetch(:advisory_locks, true) + ) + end + + def replica? + @config[:replica] || false + end + + # Determines whether writes are currently being prevents. + # + # Returns true if the connection is a replica, or if +prevent_writes+ + # is set to true. + def preventing_writes? + replica? || prevent_writes + end + + # Prevent writing to the database regardless of role. + # + # In some cases you may want to prevent writes to the database + # even if you are on a database that can write. `while_preventing_writes` + # will prevent writes to the database for the duration of the block. + def while_preventing_writes + original, @prevent_writes = @prevent_writes, true + yield + ensure + @prevent_writes = original end def migrations_paths # :nodoc: @@ -137,6 +177,10 @@ module ActiveRecord def <=>(version_string) @version <=> version_string.split(".").map(&:to_i) end + + def to_s + @version.join(".") + end end def valid_type?(type) # :nodoc: @@ -146,7 +190,7 @@ module ActiveRecord # this method must only be called while holding connection pool's mutex def lease if in_use? - msg = "Cannot lease connection, ".dup + msg = +"Cannot lease connection, " if @owner == Thread.current msg << "it is already leased by the current thread." else @@ -290,12 +334,18 @@ module ActiveRecord def supports_foreign_keys_in_create? supports_foreign_keys? end + deprecate :supports_foreign_keys_in_create? # Does this adapter support views? def supports_views? false end + # Does this adapter support materialized views? + def supports_materialized_views? + false + end + # Does this adapter support datetime with precision? def supports_datetime_with_precision? false @@ -320,6 +370,7 @@ module ActiveRecord def supports_multi_insert? true end + deprecate :supports_multi_insert? # Does this adapter support virtual columns? def supports_virtual_columns? @@ -331,6 +382,31 @@ module ActiveRecord false end + # Does this adapter support optimizer hints? + def supports_optimizer_hints? + false + end + + def supports_lazy_transactions? + false + end + + def supports_insert_returning? + false + end + + def supports_insert_on_duplicate_skip? + false + end + + def supports_insert_on_duplicate_update? + false + end + + def supports_insert_conflict_target? + false + end + # This is meant to be implemented by the adapters that support extensions def disable_extension(name) end @@ -339,6 +415,10 @@ module ActiveRecord def enable_extension(name) end + def advisory_locks_enabled? # :nodoc: + supports_advisory_locks? && @advisory_locks_enabled + end + # This is meant to be implemented by the adapters that support advisory # locks # @@ -416,11 +496,9 @@ module ActiveRecord # this should be overridden by concrete adapters end - ### - # Clear any caching the database adapter may be doing, for example - # clearing the prepared statement cache. This is database specific. + # Clear any caching the database adapter may be doing. def clear_cache! - # this should be overridden by concrete adapters + @lock.synchronize { @statements.clear } if @statements end # Returns true if its required to reload the connection between requests for development mode. @@ -442,18 +520,25 @@ module ActiveRecord # This is useful for when you need to call a proprietary method such as # PostgreSQL's lo_* methods. def raw_connection + disable_lazy_transactions! @connection end - def case_sensitive_comparison(table, attribute, column, value) # :nodoc: - table[attribute].eq(value) + def default_uniqueness_comparison(attribute, value, klass) # :nodoc: + attribute.eq(value) + end + + def case_sensitive_comparison(attribute, value) # :nodoc: + attribute.eq(value) end - def case_insensitive_comparison(table, attribute, column, value) # :nodoc: + def case_insensitive_comparison(attribute, value) # :nodoc: + column = column_for_attribute(attribute) + if can_perform_case_insensitive_comparison_for?(column) - table[attribute].lower.eq(table.lower(value)) + attribute.lower.eq(attribute.relation.lower(value)) else - table[attribute].eq(value) + attribute.eq(value) end end @@ -468,18 +553,38 @@ module ActiveRecord end def column_name_for_operation(operation, node) # :nodoc: - column_name_from_arel_node(node) - end - - def column_name_from_arel_node(node) # :nodoc: - visitor.accept(node, Arel::Collectors::SQLString.new).value + visitor.compile(node) end def default_index_type?(index) # :nodoc: index.using.nil? end + # Called by ActiveRecord::InsertAll, + # Passed an instance of ActiveRecord::InsertAll::Builder, + # This method implements standard bulk inserts for all databases, but + # should be overridden by adapters to implement common features with + # non-standard syntax like handling duplicates or returning values. + def build_insert_sql(insert) # :nodoc: + if insert.skip_duplicates? || insert.update_duplicates? + raise NotImplementedError, "#{self.class} should define `build_insert_sql` to implement adapter-specific logic for handling duplicates during INSERT" + end + + "INSERT #{insert.into} #{insert.values_list}" + end + + def get_database_version # :nodoc: + end + + def database_version # :nodoc: + schema_cache.database_version + end + + def check_version # :nodoc: + end + private + def type_map @type_map ||= Type::TypeMap.new.tap do |mapping| initialize_type_map(mapping) @@ -553,14 +658,12 @@ module ActiveRecord $1.to_i if sql_type =~ /\((.*)\)/ end - def translate_exception_class(e, sql) - begin - message = "#{e.class.name}: #{e.message}: #{sql}" - rescue Encoding::CompatibilityError - message = "#{e.class.name}: #{e.message.force_encoding sql.encoding}: #{sql}" - end + def translate_exception_class(e, sql, binds) + message = "#{e.class.name}: #{e.message}" - exception = translate_exception(e, message) + exception = translate_exception( + e, message: message, sql: sql, binds: binds + ) exception.set_backtrace e.backtrace exception end @@ -573,24 +676,23 @@ module ActiveRecord binds: binds, type_casted_binds: type_casted_binds, statement_name: statement_name, - connection_id: object_id) do - begin - @lock.synchronize do - yield - end - rescue => e - raise translate_exception_class(e, sql) + connection_id: object_id, + connection: self) do + @lock.synchronize do + yield end + rescue => e + raise translate_exception_class(e, sql, binds) end end - def translate_exception(exception, message) + def translate_exception(exception, message:, sql:, binds:) # override in derived class case exception when RuntimeError exception else - ActiveRecord::StatementInvalid.new(message) + ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds) end end @@ -604,6 +706,11 @@ module ActiveRecord raise(ActiveRecordError, "No such column: #{table_name}.#{column_name}") end + def column_for_attribute(attribute) + table_name = attribute.relation.name + schema_cache.columns_hash(table_name)[attribute.name.to_s] + end + def collector if prepared_statements Arel::Collectors::Composite.new( @@ -621,6 +728,9 @@ module ActiveRecord def arel_visitor Arel::Visitors::ToSql.new(self) end + + def build_statement_pool + end end end end 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 fbedddd7f9..ca8bbc14da 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb @@ -29,7 +29,7 @@ module ActiveRecord NATIVE_DATABASE_TYPES = { primary_key: "bigint auto_increment PRIMARY KEY", string: { name: "varchar", limit: 255 }, - text: { name: "text", limit: 65535 }, + text: { name: "text" }, integer: { name: "int", limit: 4 }, float: { name: "float", limit: 24 }, decimal: { name: "decimal" }, @@ -37,29 +37,26 @@ module ActiveRecord timestamp: { name: "timestamp" }, time: { name: "time" }, date: { name: "date" }, - binary: { name: "blob", limit: 65535 }, + binary: { name: "blob" }, + blob: { name: "blob" }, boolean: { name: "tinyint", limit: 1 }, json: { name: "json" }, } class StatementPool < ConnectionAdapters::StatementPool # :nodoc: - private def dealloc(stmt) - stmt[:stmt].close - end + private + + def dealloc(stmt) + stmt.close + end end def initialize(connection, logger, connection_options, config) super(connection, logger, config) - - @statements = StatementPool.new(self.class.type_cast_config_to_integer(config[:statement_limit])) - - if version < "5.1.10" - raise "Your version of MySQL (#{version_string}) is too old. Active Record supports MySQL >= 5.1.10." - end end - def version #:nodoc: - @version ||= Version.new(version_string) + def get_database_version #:nodoc: + Version.new(version_string) end def mariadb? # :nodoc: @@ -71,7 +68,11 @@ module ActiveRecord end def supports_index_sort_order? - !mariadb? && version >= "8.0.1" + !mariadb? && database_version >= "8.0.1" + end + + def supports_expression_index? + !mariadb? && database_version >= "8.0.13" end def supports_transaction_isolation? @@ -95,25 +96,30 @@ module ActiveRecord end def supports_datetime_with_precision? - if mariadb? - version >= "5.3.0" - else - version >= "5.6.4" - end + mariadb? || database_version >= "5.6.4" end def supports_virtual_columns? - if mariadb? - version >= "5.2.0" - else - version >= "5.7.5" - end + mariadb? || database_version >= "5.7.5" + end + + # See https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html for more details. + def supports_optimizer_hints? + !mariadb? && database_version >= "5.7.7" end def supports_advisory_locks? true end + def supports_insert_on_duplicate_skip? + true + end + + def supports_insert_on_duplicate_update? + true + end + def get_advisory_lock(lock_name, timeout = 0) # :nodoc: query_value("SELECT GET_LOCK(#{quote(lock_name.to_s)}, #{timeout})") == 1 end @@ -127,7 +133,7 @@ module ActiveRecord end def index_algorithms - { default: "ALGORITHM = DEFAULT".dup, copy: "ALGORITHM = COPY".dup, inplace: "ALGORITHM = INPLACE".dup } + { default: +"ALGORITHM = DEFAULT", copy: +"ALGORITHM = COPY", inplace: +"ALGORITHM = INPLACE" } end # HELPER METHODS =========================================== @@ -159,10 +165,9 @@ module ActiveRecord # CONNECTION MANAGEMENT ==================================== - # Clears the prepared statements cache. - def clear_cache! + def clear_cache! # :nodoc: reload_type_map - @statements.clear + super end #-- @@ -171,15 +176,17 @@ module ActiveRecord def explain(arel, binds = []) sql = "EXPLAIN #{to_sql(arel, binds)}" - start = Time.now + start = Concurrent.monotonic_time result = exec_query(sql, "EXPLAIN", binds) - elapsed = Time.now - start + elapsed = Concurrent.monotonic_time - start MySQL::ExplainPrettyPrinter.new.pp(result, elapsed) end # Executes the SQL statement in the context of this connection. def execute(sql, name = nil) + materialize_transactions + log(sql, name) do ActiveSupport::Dependencies.interlock.permit_concurrent_loads do @connection.query(sql) @@ -211,19 +218,7 @@ module ActiveRecord execute "ROLLBACK" end - # In the simple case, MySQL allows us to place JOINs directly into the UPDATE - # query. However, this does not allow for LIMIT, OFFSET and ORDER. To support - # these, we must use a subquery. - def join_to_update(update, select, key) # :nodoc: - if select.limit || select.offset || select.orders.any? - super - else - update.table select.source - update.wheres = select.constraints - end - end - - def empty_insert_statement_value + def empty_insert_statement_value(primary_key = nil) "VALUES ()" end @@ -239,7 +234,7 @@ module ActiveRecord end # Create a new MySQL database with optional <tt>:charset</tt> and <tt>:collation</tt>. - # Charset defaults to utf8. + # Charset defaults to utf8mb4. # # Example: # create_database 'charset_test', charset: 'latin1', collation: 'latin1_bin' @@ -248,8 +243,12 @@ module ActiveRecord def create_database(name, options = {}) if options[:collation] execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT COLLATE #{quote_table_name(options[:collation])}" + elsif options[:charset] + execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT CHARACTER SET #{quote_table_name(options[:charset])}" + elsif row_format_dynamic_by_default? + execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT CHARACTER SET `utf8mb4`" else - execute "CREATE DATABASE #{quote_table_name(name)} DEFAULT CHARACTER SET #{quote_table_name(options[:charset] || 'utf8')}" + raise "Configure a supported :charset and ensure innodb_large_prefix is enabled to support indexes on varchar(255) string columns." end end @@ -275,10 +274,6 @@ module ActiveRecord show_variable "collation_database" end - def truncate(table_name, name = nil) - execute "TRUNCATE TABLE #{quote_table_name(table_name)}", name - end - def table_comment(table_name) # :nodoc: scope = quoted_scope(table_name) @@ -376,7 +371,7 @@ module ActiveRecord def add_index(table_name, column_name, options = {}) #:nodoc: index_name, index_type, index_columns, _, index_algorithm, index_using, comment = add_index_options(table_name, column_name, options) - sql = "CREATE #{index_type} INDEX #{quote_column_name(index_name)} #{index_using} ON #{quote_table_name(table_name)} (#{index_columns}) #{index_algorithm}".dup + sql = +"CREATE #{index_type} INDEX #{quote_column_name(index_name)} #{index_using} ON #{quote_table_name(table_name)} (#{index_columns}) #{index_algorithm}" execute add_sql_comment!(sql, comment) end @@ -442,30 +437,6 @@ module ActiveRecord table_options end - # Maps logical Rails types to MySQL-specific data types. - def type_to_sql(type, limit: nil, precision: nil, scale: nil, unsigned: nil, **) # :nodoc: - sql = \ - case type.to_s - when "integer" - integer_to_sql(limit) - when "text" - text_to_sql(limit) - when "blob" - binary_to_sql(limit) - when "binary" - if (0..0xfff) === limit - "varbinary(#{limit})" - else - binary_to_sql(limit) - end - else - super - end - - sql = "#{sql} unsigned" if unsigned && type != :primary_key - sql - end - # SHOW VARIABLES LIKE 'name' def show_variable(name) query_value("SELECT @@#{name}", "SCHEMA") @@ -488,9 +459,26 @@ module ActiveRecord SQL end - def case_sensitive_comparison(table, attribute, column, value) # :nodoc: + def default_uniqueness_comparison(attribute, value, klass) # :nodoc: + column = column_for_attribute(attribute) + + if column.collation && !column.case_sensitive? && !value.nil? + ActiveSupport::Deprecation.warn(<<~MSG.squish) + Uniqueness validator will no longer enforce case sensitive comparison in Rails 6.1. + To continue case sensitive comparison on the :#{attribute.name} attribute in #{klass} model, + pass `case_sensitive: true` option explicitly to the uniqueness validator. + MSG + attribute.eq(Arel::Nodes::Bin.new(value)) + else + super + end + end + + def case_sensitive_comparison(attribute, value) # :nodoc: + column = column_for_attribute(attribute) + if column.collation && !column.case_sensitive? - table[attribute].eq(Arel::Nodes::Bin.new(value)) + attribute.eq(Arel::Nodes::Bin.new(value)) else super end @@ -524,50 +512,27 @@ module ActiveRecord index.using == :btree || super end - def insert_fixtures_set(fixture_set, tables_to_delete = []) - with_multi_statements do - super { discard_remaining_results } - end - end + def build_insert_sql(insert) # :nodoc: + sql = +"INSERT #{insert.into} #{insert.values_list}" - private - def combine_multi_statements(total_sql) - total_sql.each_with_object([]) do |sql, total_sql_chunks| - previous_packet = total_sql_chunks.last - sql << ";\n" - if max_allowed_packet_reached?(sql, previous_packet) || total_sql_chunks.empty? - total_sql_chunks << sql - else - previous_packet << sql - end - end + if insert.skip_duplicates? + no_op_column = quote_column_name(insert.keys.first) + sql << " ON DUPLICATE KEY UPDATE #{no_op_column}=#{no_op_column}" + elsif insert.update_duplicates? + sql << " ON DUPLICATE KEY UPDATE " + sql << insert.updatable_columns.map { |column| "#{column}=VALUES(#{column})" }.join(",") end - def max_allowed_packet_reached?(current_packet, previous_packet) - if current_packet.bytesize > max_allowed_packet - raise ActiveRecordError, "Fixtures set is too large #{current_packet.bytesize}. Consider increasing the max_allowed_packet variable." - elsif previous_packet.nil? - false - else - (current_packet.bytesize + previous_packet.bytesize) > max_allowed_packet - end - end + sql + end - def max_allowed_packet - bytes_margin = 2 - @max_allowed_packet ||= (show_variable("max_allowed_packet") - bytes_margin) + def check_version # :nodoc: + if database_version < "5.5.8" + raise "Your version of MySQL (#{database_version}) is too old. Active Record supports MySQL >= 5.5.8." end + end - def with_multi_statements - previous_flags = @config[:flags] - @config[:flags] = Mysql2::Client::MULTI_STATEMENTS - reconnect! - - yield - ensure - @config[:flags] = previous_flags - reconnect! - end + private def initialize_type_map(m = type_map) super @@ -596,13 +561,13 @@ module ActiveRecord m.alias_type %r(bit)i, "binary" m.register_type(%r(enum)i) do |sql_type| - limit = sql_type[/^enum\((.+)\)/i, 1] + limit = sql_type[/^enum\s*\((.+)\)/i, 1] .split(",").map { |enum| enum.strip.length - 2 }.max MysqlString.new(limit: limit) end m.register_type(%r(^set)i) do |sql_type| - limit = sql_type[/^set\((.+)\)/i, 1] + limit = sql_type[/^set\s*\((.+)\)/i, 1] .split(",").map { |set| set.strip.length - 1 }.sum - 1 MysqlString.new(limit: limit) end @@ -629,7 +594,10 @@ module ActiveRecord # See https://dev.mysql.com/doc/refman/5.7/en/error-messages-server.html ER_DUP_ENTRY = 1062 ER_NOT_NULL_VIOLATION = 1048 + ER_NO_REFERENCED_ROW = 1216 + ER_ROW_IS_REFERENCED = 1217 ER_DO_NOT_HAVE_DEFAULT = 1364 + ER_ROW_IS_REFERENCED_2 = 1451 ER_NO_REFERENCED_ROW_2 = 1452 ER_DATA_TOO_LONG = 1406 ER_OUT_OF_RANGE = 1264 @@ -639,35 +607,36 @@ module ActiveRecord ER_LOCK_WAIT_TIMEOUT = 1205 ER_QUERY_INTERRUPTED = 1317 ER_QUERY_TIMEOUT = 3024 + ER_FK_INCOMPATIBLE_COLUMNS = 3780 - def translate_exception(exception, message) + def translate_exception(exception, message:, sql:, binds:) case error_number(exception) when ER_DUP_ENTRY - RecordNotUnique.new(message) - when ER_NO_REFERENCED_ROW_2 - InvalidForeignKey.new(message) - when ER_CANNOT_ADD_FOREIGN - mismatched_foreign_key(message) + RecordNotUnique.new(message, sql: sql, binds: binds) + when ER_NO_REFERENCED_ROW, ER_ROW_IS_REFERENCED, ER_ROW_IS_REFERENCED_2, ER_NO_REFERENCED_ROW_2 + InvalidForeignKey.new(message, sql: sql, binds: binds) + when ER_CANNOT_ADD_FOREIGN, ER_FK_INCOMPATIBLE_COLUMNS + mismatched_foreign_key(message, sql: sql, binds: binds) when ER_CANNOT_CREATE_TABLE if message.include?("errno: 150") - mismatched_foreign_key(message) + mismatched_foreign_key(message, sql: sql, binds: binds) else super end when ER_DATA_TOO_LONG - ValueTooLong.new(message) + ValueTooLong.new(message, sql: sql, binds: binds) when ER_OUT_OF_RANGE - RangeError.new(message) + RangeError.new(message, sql: sql, binds: binds) when ER_NOT_NULL_VIOLATION, ER_DO_NOT_HAVE_DEFAULT - NotNullViolation.new(message) + NotNullViolation.new(message, sql: sql, binds: binds) when ER_LOCK_DEADLOCK - Deadlocked.new(message) + Deadlocked.new(message, sql: sql, binds: binds) when ER_LOCK_WAIT_TIMEOUT - LockWaitTimeout.new(message) + LockWaitTimeout.new(message, sql: sql, binds: binds) when ER_QUERY_TIMEOUT - StatementTimeout.new(message) + StatementTimeout.new(message, sql: sql, binds: binds) when ER_QUERY_INTERRUPTED - QueryCanceled.new(message) + QueryCanceled.new(message, sql: sql, binds: binds) else super end @@ -720,6 +689,12 @@ module ActiveRecord end def add_timestamps_for_alter(table_name, options = {}) + options[:null] = false if options[:null].nil? + + if !options.key?(:precision) && supports_datetime_with_precision? + options[:precision] = 6 + end + [add_column_for_alter(table_name, :created_at, :datetime, options), add_column_for_alter(table_name, :updated_at, :datetime, options)] end @@ -727,22 +702,8 @@ module ActiveRecord [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 - # to give it some prompting in the form of a subsubquery. Ugh! - def subquery_for(key, select) - subselect = select.clone - subselect.projections = [key] - - # Materialize subquery by adding distinct - # to work with MySQL 5.7.6 which sets optimizer_switch='derived_merge=on' - subselect.distinct unless select.limit || select.offset || select.orders.any? - - key_name = quote_column_name(key.name) - Arel::SelectManager.new(subselect.as("__active_record_temp")).project(Arel.sql(key_name)) - end - def supports_rename_index? - mariadb? ? false : version >= "5.7.6" + mariadb? ? false : database_version >= "5.7.6" end def configure_connection @@ -779,7 +740,7 @@ module ActiveRecord # https://dev.mysql.com/doc/refman/5.7/en/set-names.html # (trailing comma because variable_assignments will always have content) if @config[:encoding] - encoding = "NAMES #{@config[:encoding]}".dup + encoding = +"NAMES #{@config[:encoding]}" encoding << " COLLATE #{@config[:collation]}" if @config[:collation] encoding << ", " end @@ -812,47 +773,32 @@ module ActiveRecord Arel::Visitors::MySQL.new(self) end - def mismatched_foreign_key(message) - parts = message.scan(/`(\w+)`[ $)]/).flatten - MismatchedForeignKey.new( - self, - message: message, - table: parts[0], - foreign_key: parts[1], - target_table: parts[2], - primary_key: parts[3], - ) + def build_statement_pool + StatementPool.new(self.class.type_cast_config_to_integer(@config[:statement_limit])) end - def integer_to_sql(limit) # :nodoc: - case limit - when 1; "tinyint" - when 2; "smallint" - when 3; "mediumint" - when nil, 4; "int" - when 5..8; "bigint" - else raise(ActiveRecordError, "No integer type has byte size #{limit}. Use a decimal with scale 0 instead.") - end - end + def mismatched_foreign_key(message, sql:, binds:) + match = %r/ + (?:CREATE|ALTER)\s+TABLE\s*(?:`?\w+`?\.)?`?(?<table>\w+)`?.+? + FOREIGN\s+KEY\s*\(`?(?<foreign_key>\w+)`?\)\s* + REFERENCES\s*(`?(?<target_table>\w+)`?)\s*\(`?(?<primary_key>\w+)`?\) + /xmi.match(sql) - def text_to_sql(limit) # :nodoc: - case limit - when 0..0xff; "tinytext" - when nil, 0x100..0xffff; "text" - when 0x10000..0xffffff; "mediumtext" - when 0x1000000..0xffffffff; "longtext" - else raise(ActiveRecordError, "No text type has byte length #{limit}") - end - end + options = { + message: message, + sql: sql, + binds: binds, + } - def binary_to_sql(limit) # :nodoc: - case limit - when 0..0xff; "tinyblob" - when nil, 0x100..0xffff; "blob" - when 0x10000..0xffffff; "mediumblob" - when 0x1000000..0xffffffff; "longblob" - else raise(ActiveRecordError, "No binary type has byte length #{limit}") + if match + options[:table] = match[:table] + options[:foreign_key] = match[:foreign_key] + options[:target_table] = match[:target_table] + options[:primary_key] = match[:primary_key] + options[:primary_key_column] = column_for(match[:target_table], match[:primary_key]) end + + MismatchedForeignKey.new(options) end def version_string diff --git a/activerecord/lib/active_record/connection_adapters/column.rb b/activerecord/lib/active_record/connection_adapters/column.rb index 5d81de9fe1..279d0b9e84 100644 --- a/activerecord/lib/active_record/connection_adapters/column.rb +++ b/activerecord/lib/active_record/connection_adapters/column.rb @@ -5,7 +5,7 @@ module ActiveRecord module ConnectionAdapters # An abstract definition of a column in a table. class Column - attr_reader :name, :default, :sql_type_metadata, :null, :table_name, :default_function, :collation, :comment + attr_reader :name, :default, :sql_type_metadata, :null, :default_function, :collation, :comment delegate :precision, :scale, :limit, :type, :sql_type, to: :sql_type_metadata, allow_nil: true @@ -15,9 +15,8 @@ module ActiveRecord # +default+ is the type-casted default value, such as +new+ in <tt>sales_stage varchar(20) default 'new'</tt>. # +sql_type_metadata+ is various information about the type of the column # +null+ determines if this column allows +NULL+ values. - def initialize(name, default, sql_type_metadata = nil, null = true, table_name = nil, default_function = nil, collation = nil, comment: nil, **) + def initialize(name, default, sql_type_metadata = nil, null = true, default_function = nil, collation: nil, comment: nil, **) @name = name.freeze - @table_name = table_name @sql_type_metadata = sql_type_metadata @null = null @default = default @@ -44,7 +43,6 @@ module ActiveRecord def init_with(coder) @name = coder["name"] - @table_name = coder["table_name"] @sql_type_metadata = coder["sql_type_metadata"] @null = coder["null"] @default = coder["default"] @@ -55,7 +53,6 @@ module ActiveRecord def encode_with(coder) coder["name"] = @name - coder["table_name"] = @table_name coder["sql_type_metadata"] = @sql_type_metadata coder["null"] = @null coder["default"] = @default @@ -66,19 +63,26 @@ module ActiveRecord def ==(other) other.is_a?(Column) && - attributes_for_hash == other.attributes_for_hash + name == other.name && + default == other.default && + sql_type_metadata == other.sql_type_metadata && + null == other.null && + default_function == other.default_function && + collation == other.collation && + comment == other.comment end alias :eql? :== def hash - attributes_for_hash.hash + Column.hash ^ + name.hash ^ + default.hash ^ + sql_type_metadata.hash ^ + null.hash ^ + default_function.hash ^ + collation.hash ^ + comment.hash end - - protected - - def attributes_for_hash - [self.class, name, default, sql_type_metadata, null, table_name, default_function, collation] - end end class NullColumn < Column diff --git a/activerecord/lib/active_record/connection_adapters/connection_specification.rb b/activerecord/lib/active_record/connection_adapters/connection_specification.rb index 901717ae3d..9eaf9d9a89 100644 --- a/activerecord/lib/active_record/connection_adapters/connection_specification.rb +++ b/activerecord/lib/active_record/connection_adapters/connection_specification.rb @@ -57,9 +57,7 @@ module ActiveRecord private - def uri - @uri - end + attr_reader :uri def uri_parser @uri_parser ||= URI::Parser.new @@ -116,8 +114,7 @@ module ActiveRecord class Resolver # :nodoc: attr_reader :configurations - # Accepts a hash two layers deep, keys on the first layer represent - # environments such as "production". Keys must be strings. + # Accepts a list of db config objects. def initialize(configurations) @configurations = configurations end @@ -138,33 +135,14 @@ module ActiveRecord # Resolver.new(configurations).resolve(:production) # # => { "host" => "localhost", "database" => "foo", "adapter" => "postgresql" } # - def resolve(config) - if config - resolve_connection config - elsif env = ActiveRecord::ConnectionHandling::RAILS_ENV.call - resolve_symbol_connection env.to_sym + def resolve(config_or_env, pool_name = nil) + if config_or_env + resolve_connection config_or_env, pool_name else raise AdapterNotSpecified end end - # Expands each key in @configurations hash into fully resolved hash - def resolve_all - config = configurations.dup - - if env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call - env_config = config[env] if config[env].is_a?(Hash) && !(config[env].key?("adapter") || config[env].key?("url")) - end - - config.merge! env_config if env_config - - config.each do |key, value| - config[key] = resolve(value) if value - end - - config - end - # Returns an instance of ConnectionSpecification for a given adapter. # Accepts a hash one layer deep that contains all connection information. # @@ -178,7 +156,9 @@ module ActiveRecord # # => { "host" => "localhost", "database" => "foo", "adapter" => "sqlite3" } # def spec(config) - spec = resolve(config).symbolize_keys + pool_name = config if config.is_a?(Symbol) + + spec = resolve(config, pool_name).symbolize_keys raise(AdapterNotSpecified, "database configuration does not specify adapter") unless spec.key?(:adapter) @@ -194,12 +174,12 @@ module ActiveRecord if e.path == path_to_adapter # We can assume that a non-builtin adapter was specified, so it's # either misspelled or missing from Gemfile. - raise e.class, "Could not load the '#{spec[:adapter]}' Active Record adapter. Ensure that the adapter is spelled correctly in config/database.yml and that you've added the necessary adapter gem to your Gemfile.", e.backtrace + raise LoadError, "Could not load the '#{spec[:adapter]}' Active Record adapter. Ensure that the adapter is spelled correctly in config/database.yml and that you've added the necessary adapter gem to your Gemfile.", e.backtrace # Bubbled up from the adapter require. Prefix the exception message # with some guidance about how to address it and reraise. else - raise e.class, "Error loading the '#{spec[:adapter]}' Active Record adapter. Missing a gem it depends on? #{e.message}", e.backtrace + raise LoadError, "Error loading the '#{spec[:adapter]}' Active Record adapter. Missing a gem it depends on? #{e.message}", e.backtrace end end @@ -213,7 +193,6 @@ module ActiveRecord end private - # Returns fully resolved connection, accepts hash, string or symbol. # Always returns a hash. # @@ -234,32 +213,64 @@ module ActiveRecord # Resolver.new({}).resolve_connection("postgresql://localhost/foo") # # => { "host" => "localhost", "database" => "foo", "adapter" => "postgresql" } # - def resolve_connection(spec) - case spec + def resolve_connection(config_or_env, pool_name = nil) + case config_or_env when Symbol - resolve_symbol_connection spec + resolve_symbol_connection config_or_env, pool_name when String - resolve_url_connection spec + resolve_url_connection config_or_env when Hash - resolve_hash_connection spec + resolve_hash_connection config_or_env + else + resolve_connection config_or_env end end - # Takes the environment such as +:production+ or +:development+. + # Takes the environment such as +:production+ or +:development+ and a + # pool name the corresponds to the name given by the connection pool + # to the connection. That pool name is merged into the hash with the + # name key. + # # This requires that the @configurations was initialized with a key that # matches. # - # Resolver.new("production" => {}).resolve_symbol_connection(:production) - # # => {} + # configurations = #<ActiveRecord::DatabaseConfigurations:0x00007fd9fdace3e0 + # @configurations=[ + # #<ActiveRecord::DatabaseConfigurations::HashConfig:0x00007fd9fdace250 + # @env_name="production", @spec_name="primary", @config={"database"=>"my_db"}> + # ]> # - def resolve_symbol_connection(spec) - if config = configurations[spec.to_s] - resolve_connection(config).merge("name" => spec.to_s) + # Resolver.new(configurations).resolve_symbol_connection(:production, "primary") + # # => { "database" => "my_db" } + def resolve_symbol_connection(env_name, pool_name) + db_config = configurations.find_db_config(env_name) + + if db_config + resolve_connection(db_config.config).merge("name" => pool_name.to_s) else - raise(AdapterNotSpecified, "'#{spec}' database is not configured. Available: #{configurations.keys.inspect}") + raise AdapterNotSpecified, <<~MSG + The `#{env_name}` database is not configured for the `#{ActiveRecord::ConnectionHandling::DEFAULT_ENV.call}` environment. + + Available databases configurations are: + + #{build_configuration_sentence} + MSG end end + def build_configuration_sentence # :nodoc: + configs = configurations.configs_for(include_replicas: true) + + configs.group_by(&:env_name).map do |env, config| + namespaces = config.map(&:spec_name) + if namespaces.size > 1 + "#{env}: #{namespaces.join(", ")}" + else + env + end + end.join("\n") + end + # Accepts a hash. Expands the "url" key that contains a # URL database connection to a full connection # hash and merges with the rest of the hash. diff --git a/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb b/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb index 3dcb916d99..1df4dea2d8 100644 --- a/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb +++ b/activerecord/lib/active_record/connection_adapters/determine_if_preparable_visitor.rb @@ -3,14 +3,19 @@ module ActiveRecord module ConnectionAdapters module DetermineIfPreparableVisitor - attr_reader :preparable + attr_accessor :preparable def accept(*) @preparable = true super end - def visit_Arel_Nodes_In(*) + def visit_Arel_Nodes_In(o, collector) + @preparable = false + super + end + + def visit_Arel_Nodes_NotIn(o, collector) @preparable = false super end diff --git a/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb b/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb index 458c9bfd70..2132e5d248 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb @@ -11,7 +11,7 @@ module ActiveRecord else super end - discard_remaining_results + @connection.abandon_results! result end @@ -19,8 +19,19 @@ module ActiveRecord execute(sql, name).to_a end + READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp(:begin, :commit, :explain, :select, :set, :show, :release, :savepoint, :rollback) # :nodoc: + private_constant :READ_QUERY + + def write_query?(sql) # :nodoc: + !READ_QUERY.match?(sql) + end + # Executes the SQL statement in the context of this connection. def execute(sql, name = nil) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + # make sure we carry over any changes to ActiveRecord::Base.default_timezone that have been # made since we established the connection @connection.query_options[:database_timezone] = ActiveRecord::Base.default_timezone @@ -31,18 +42,28 @@ module ActiveRecord def exec_query(sql, name = "SQL", binds = [], prepare: false) if without_prepared_statement?(binds) execute_and_free(sql, name) do |result| - ActiveRecord::Result.new(result.fields, result.to_a) if result + if result + ActiveRecord::Result.new(result.fields, result.to_a) + else + ActiveRecord::Result.new([], []) + end end else exec_stmt_and_free(sql, name, binds, cache_stmt: prepare) do |_, result| - ActiveRecord::Result.new(result.fields, result.to_a) if result + if result + ActiveRecord::Result.new(result.fields, result.to_a) + else + ActiveRecord::Result.new([], []) + end end end end def exec_delete(sql, name = nil, binds = []) if without_prepared_statement?(binds) - execute_and_free(sql, name) { @connection.affected_rows } + @lock.synchronize do + execute_and_free(sql, name) { @connection.affected_rows } + end else exec_stmt_and_free(sql, name, binds) { |stmt| stmt.affected_rows } end @@ -50,19 +71,97 @@ module ActiveRecord alias :exec_update :exec_delete private + def execute_batch(sql, name = nil) + super + @connection.abandon_results! + end + def default_insert_value(column) - Arel.sql("DEFAULT") unless column.auto_increment? + super unless column.auto_increment? end def last_inserted_id(result) @connection.last_id end - def discard_remaining_results - @connection.abandon_results! + def supports_set_server_option? + @connection.respond_to?(:set_server_option) + end + + def build_truncate_statements(*table_names) + if table_names.size == 1 + super.first + else + super + end + end + + def multi_statements_enabled?(flags) + if flags.is_a?(Array) + flags.include?("MULTI_STATEMENTS") + else + (flags & Mysql2::Client::MULTI_STATEMENTS) != 0 + end + end + + def with_multi_statements + previous_flags = @config[:flags] + + unless multi_statements_enabled?(previous_flags) + if supports_set_server_option? + @connection.set_server_option(Mysql2::Client::OPTION_MULTI_STATEMENTS_ON) + else + @config[:flags] = Mysql2::Client::MULTI_STATEMENTS + reconnect! + end + end + + yield + ensure + unless multi_statements_enabled?(previous_flags) + if supports_set_server_option? + @connection.set_server_option(Mysql2::Client::OPTION_MULTI_STATEMENTS_OFF) + else + @config[:flags] = previous_flags + reconnect! + end + end + end + + def combine_multi_statements(total_sql) + total_sql.each_with_object([]) do |sql, total_sql_chunks| + previous_packet = total_sql_chunks.last + if max_allowed_packet_reached?(sql, previous_packet) + total_sql_chunks << +sql + else + previous_packet << ";\n" + previous_packet << sql + end + end + end + + def max_allowed_packet_reached?(current_packet, previous_packet) + if current_packet.bytesize > max_allowed_packet + raise ActiveRecordError, + "Fixtures set is too large #{current_packet.bytesize}. Consider increasing the max_allowed_packet variable." + elsif previous_packet.nil? + true + else + (current_packet.bytesize + previous_packet.bytesize + 2) > max_allowed_packet + end + end + + def max_allowed_packet + @max_allowed_packet ||= show_variable("max_allowed_packet") end def exec_stmt_and_free(sql, name, binds, cache_stmt: false) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + + materialize_transactions + # make sure we carry over any changes to ActiveRecord::Base.default_timezone that have been # made since we established the connection @connection.query_options[:database_timezone] = ActiveRecord::Base.default_timezone @@ -71,10 +170,7 @@ module ActiveRecord log(sql, name, binds, type_casted_binds) do if cache_stmt - cache = @statements[sql] ||= { - stmt: @connection.prepare(sql) - } - stmt = cache[:stmt] + stmt = @statements[sql] ||= @connection.prepare(sql) else stmt = @connection.prepare(sql) end diff --git a/activerecord/lib/active_record/connection_adapters/mysql/quoting.rb b/activerecord/lib/active_record/connection_adapters/mysql/quoting.rb index be038403b8..75564a61d6 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/quoting.rb @@ -5,7 +5,7 @@ module ActiveRecord module MySQL module Quoting # :nodoc: def quote_column_name(name) - @quoted_column_names[name] ||= "`#{super.gsub('`', '``')}`".freeze + @quoted_column_names[name] ||= "`#{super.gsub('`', '``')}`" end def quote_table_name(name) diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb index c9ea653b77..82ed320617 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb @@ -17,7 +17,7 @@ module ActiveRecord end def visit_ChangeColumnDefinition(o) - change_column_sql = "CHANGE #{quote_column_name(o.name)} #{accept(o.column)}".dup + change_column_sql = +"CHANGE #{quote_column_name(o.name)} #{accept(o.column)}" add_column_position!(change_column_sql, column_options(o.column)) end @@ -64,7 +64,7 @@ module ActiveRecord def index_in_create(table_name, column_name, options) index_name, index_type, index_columns, _, _, index_using, comment = @conn.add_index_options(table_name, column_name, options) - add_sql_comment!("#{index_type} INDEX #{quote_column_name(index_name)} #{index_using} (#{index_columns})".dup, comment) + add_sql_comment!((+"#{index_type} INDEX #{quote_column_name(index_name)} #{index_using} (#{index_columns})"), comment) end end end 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 2ed4ad16ae..d21535a709 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb @@ -4,48 +4,56 @@ module ActiveRecord module ConnectionAdapters module MySQL module ColumnMethods - def blob(*args, **options) - args.each { |name| column(name, :blob, options) } - end + extend ActiveSupport::Concern - def tinyblob(*args, **options) - args.each { |name| column(name, :tinyblob, options) } - end + ## + # :method: blob + # :call-seq: blob(*names, **options) - def mediumblob(*args, **options) - args.each { |name| column(name, :mediumblob, options) } - end + ## + # :method: tinyblob + # :call-seq: tinyblob(*names, **options) - def longblob(*args, **options) - args.each { |name| column(name, :longblob, options) } - end + ## + # :method: mediumblob + # :call-seq: mediumblob(*names, **options) - def tinytext(*args, **options) - args.each { |name| column(name, :tinytext, options) } - end + ## + # :method: longblob + # :call-seq: longblob(*names, **options) - def mediumtext(*args, **options) - args.each { |name| column(name, :mediumtext, options) } - end + ## + # :method: tinytext + # :call-seq: tinytext(*names, **options) - def longtext(*args, **options) - args.each { |name| column(name, :longtext, options) } - end + ## + # :method: mediumtext + # :call-seq: mediumtext(*names, **options) - def unsigned_integer(*args, **options) - args.each { |name| column(name, :unsigned_integer, options) } - end + ## + # :method: longtext + # :call-seq: longtext(*names, **options) - def unsigned_bigint(*args, **options) - args.each { |name| column(name, :unsigned_bigint, options) } - end + ## + # :method: unsigned_integer + # :call-seq: unsigned_integer(*names, **options) - def unsigned_float(*args, **options) - args.each { |name| column(name, :unsigned_float, options) } - end + ## + # :method: unsigned_bigint + # :call-seq: unsigned_bigint(*names, **options) + + ## + # :method: unsigned_float + # :call-seq: unsigned_float(*names, **options) + + ## + # :method: unsigned_decimal + # :call-seq: unsigned_decimal(*names, **options) - def unsigned_decimal(*args, **options) - args.each { |name| column(name, :unsigned_decimal, options) } + included do + define_column_methods :blob, :tinyblob, :mediumblob, :longblob, + :tinytext, :mediumtext, :longtext, :unsigned_integer, :unsigned_bigint, + :unsigned_float, :unsigned_decimal end end diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb index d23178e43c..234fb25fdf 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb @@ -10,6 +10,10 @@ module ActiveRecord spec[:unsigned] = "true" if column.unsigned? spec[:auto_increment] = "true" if column.auto_increment? + if /\A(?<size>tiny|medium|long)(?:text|blob)/ =~ column.sql_type + spec = { size: size.to_sym.inspect }.merge!(spec) + end + if @connection.supports_virtual_columns? && column.virtual? spec[:as] = extract_expression_for_virtual_column(column) spec[:stored] = "true" if /\b(?:STORED|PERSISTENT)\b/.match?(column.extra) @@ -37,19 +41,21 @@ module ActiveRecord case column.sql_type when /\Atimestamp\b/ :timestamp - when "tinyblob" - :blob else super end end + def schema_limit(column) + super unless /\A(?:tiny|medium|long)?(?:text|blob)/.match?(column.sql_type) + end + def schema_precision(column) super unless /\A(?:date)?time(?:stamp)?\b/.match?(column.sql_type) && column.precision == 0 end def schema_collation(column) - if column.collation && table_name = column.table_name + if column.collation @table_collation_cache ||= {} @table_collation_cache[table_name] ||= @connection.exec_query("SHOW TABLE STATUS LIKE #{@connection.quote(table_name)}", "SCHEMA").first["Collation"] @@ -58,14 +64,14 @@ module ActiveRecord end def extract_expression_for_virtual_column(column) - if @connection.mariadb? && @connection.version < "10.2.5" - create_table_info = @connection.send(:create_table_info, column.table_name) + if @connection.mariadb? && @connection.database_version < "10.2.5" + create_table_info = @connection.send(:create_table_info, table_name) column_name = @connection.quote_column_name(column.name) if %r/#{column_name} #{Regexp.quote(column.sql_type)}(?: COLLATE \w+)? AS \((?<expression>.+?)\) #{column.extra}/ =~ create_table_info $~[:expression].inspect end else - scope = @connection.send(:quoted_scope, column.table_name) + scope = @connection.send(:quoted_scope, table_name) column_name = @connection.quote(column.name) sql = "SELECT generation_expression FROM information_schema.columns" \ " WHERE table_schema = #{scope[:schema]}" \ 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 ce50590651..25a1fb234a 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb @@ -35,13 +35,39 @@ module ActiveRecord ] end - 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" + if row[:Expression] + expression = row[:Expression] + expression = +"(#{expression})" unless expression.start_with?("(") + indexes.last[-2] << expression + indexes.last[-1][:expressions] ||= {} + indexes.last[-1][:expressions][expression] = expression + indexes.last[-1][:orders][expression] = :desc if row[:Collation] == "D" + else + indexes.last[-2] << row[:Column_name] + indexes.last[-1][:lengths][row[:Column_name]] = row[:Sub_part].to_i if row[:Sub_part] + indexes.last[-1][:orders][row[:Column_name]] = :desc if row[:Collation] == "D" + end end end - indexes.map { |index| IndexDefinition.new(*index) } + indexes.map do |index| + options = index.last + + if expressions = options.delete(:expressions) + orders = options.delete(:orders) + lengths = options.delete(:lengths) + + columns = index[-2].map { |name| + [ name.to_sym, expressions[name] || +quote_column_name(name) ] + }.to_h + + index[-2] = add_options_for_index_columns( + columns, order: orders, length: lengths + ).values.join(", ") + end + + IndexDefinition.new(*index) + end end def remove_column(table_name, column_name, type = nil, options = {}) @@ -51,9 +77,13 @@ module ActiveRecord super end + def create_table(table_name, options: default_row_format, **) + super + end + def internal_string_options_for_primary_key super.tap do |options| - if CHARSETS_OF_4BYTES_MAXLEN.include?(charset) && (mariadb? || version < "8.0.0") + if !row_format_dynamic_by_default? && CHARSETS_OF_4BYTES_MAXLEN.include?(charset) options[:collation] = collation.sub(/\A[^_]+/, "utf8") end end @@ -67,23 +97,76 @@ module ActiveRecord MySQL::SchemaDumper.create(self, options) end + # Maps logical Rails types to MySQL-specific data types. + def type_to_sql(type, limit: nil, precision: nil, scale: nil, size: limit_to_size(limit, type), unsigned: nil, **) + sql = + case type.to_s + when "integer" + integer_to_sql(limit) + when "text" + type_with_size_to_sql("text", size) + when "blob" + type_with_size_to_sql("blob", size) + when "binary" + if (0..0xfff) === limit + "varbinary(#{limit})" + else + type_with_size_to_sql("blob", size) + end + else + super + end + + sql = "#{sql} unsigned" if unsigned && type != :primary_key + sql + end + + def table_alias_length + 256 # https://dev.mysql.com/doc/refman/8.0/en/identifiers.html + end + private CHARSETS_OF_4BYTES_MAXLEN = ["utf8mb4", "utf16", "utf16le", "utf32"] + def row_format_dynamic_by_default? + if mariadb? + database_version >= "10.2.2" + else + database_version >= "5.7.9" + end + end + + def default_row_format + return if row_format_dynamic_by_default? + + unless defined?(@default_row_format) + if query_value("SELECT @@innodb_file_per_table = 1 AND @@innodb_file_format = 'Barracuda'") == 1 + @default_row_format = "ROW_FORMAT=DYNAMIC" + else + @default_row_format = nil + end + end + + @default_row_format + end + def schema_creation MySQL::SchemaCreation.new(self) end def create_table_definition(*args) - MySQL::TableDefinition.new(*args) + MySQL::TableDefinition.new(self, *args) end def new_column_from_field(table_name, field) type_metadata = fetch_type_metadata(field[:Type], field[:Extra]) - if type_metadata.type == :datetime && /\ACURRENT_TIMESTAMP(?:\(\))?\z/i.match?(field[:Default]) - default, default_function = nil, "CURRENT_TIMESTAMP" - else - default, default_function = field[:Default], nil + default, default_function = field[:Default], nil + + if type_metadata.type == :datetime && /\ACURRENT_TIMESTAMP(?:\([0-6]?\))?\z/i.match?(default) + default, default_function = nil, default + elsif type_metadata.extra == "DEFAULT_GENERATED" + default = +"(#{default})" unless default.start_with?("(") + default, default_function = nil, default end MySQL::Column.new( @@ -91,9 +174,8 @@ module ActiveRecord default, type_metadata, field[:Null] == "YES", - table_name, default_function, - field[:Collation], + collation: field[:Collation], comment: field[:Comment].presence ) end @@ -121,7 +203,7 @@ module ActiveRecord def data_source_sql(name = nil, type: nil) scope = quoted_scope(name, type: type) - sql = "SELECT table_name FROM information_schema.tables".dup + sql = +"SELECT table_name FROM information_schema.tables" sql << " WHERE table_schema = #{scope[:schema]}" sql << " AND table_name = #{scope[:name]}" if scope[:name] sql << " AND table_type = #{scope[:type]}" if scope[:type] @@ -142,6 +224,40 @@ module ActiveRecord schema, name = nil, schema unless name [schema, name] end + + def type_with_size_to_sql(type, size) + case size&.to_s + when nil, "tiny", "medium", "long" + "#{size}#{type}" + else + raise ArgumentError, + "#{size.inspect} is invalid :size value. Only :tiny, :medium, and :long are allowed." + end + end + + def limit_to_size(limit, type) + case type.to_s + when "text", "blob", "binary" + case limit + when 0..0xff; "tiny" + when nil, 0x100..0xffff; nil + when 0x10000..0xffffff; "medium" + when 0x1000000..0xffffffff; "long" + else raise ArgumentError, "No #{type} type has byte size #{limit}" + end + end + end + + def integer_to_sql(limit) + case limit + when 1; "tinyint" + when 2; "smallint" + when 3; "mediumint" + when nil, 4; "int" + when 5..8; "bigint" + else raise ArgumentError, "No integer type has byte size #{limit}. Use a decimal with scale 0 instead." + end + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb b/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb index 7ad0944d51..56479f27bf 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/type_metadata.rb @@ -16,19 +16,16 @@ module ActiveRecord def ==(other) other.is_a?(MySQL::TypeMetadata) && - attributes_for_hash == other.attributes_for_hash + __getobj__ == other.__getobj__ && + extra == other.extra end alias eql? == def hash - attributes_for_hash.hash + TypeMetadata.hash ^ + __getobj__.hash ^ + extra.hash end - - protected - - def attributes_for_hash - [self.class, @type_metadata, extra] - end end end end diff --git a/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb b/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb index 4c57bd48ab..0dc880c731 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb @@ -3,7 +3,7 @@ require "active_record/connection_adapters/abstract_mysql_adapter" require "active_record/connection_adapters/mysql/database_statements" -gem "mysql2", ">= 0.4.4", "< 0.6.0" +gem "mysql2", ">= 0.4.4" require "mysql2" module ActiveRecord @@ -14,7 +14,7 @@ module ActiveRecord config[:flags] ||= 0 if config[:flags].kind_of? Array - config[:flags].push "FOUND_ROWS".freeze + config[:flags].push "FOUND_ROWS" else config[:flags] |= Mysql2::Client::FOUND_ROWS end @@ -32,7 +32,7 @@ module ActiveRecord module ConnectionAdapters class Mysql2Adapter < AbstractMysqlAdapter - ADAPTER_NAME = "Mysql2".freeze + ADAPTER_NAME = "Mysql2" include MySQL::DatabaseStatements @@ -43,7 +43,7 @@ module ActiveRecord end def supports_json? - !mariadb? && version >= "5.7.8" + !mariadb? && database_version >= "5.7.8" end def supports_comments? @@ -58,6 +58,10 @@ module ActiveRecord true end + def supports_lazy_transactions? + true + end + # HELPER METHODS =========================================== def each_hash(result) # :nodoc: @@ -117,7 +121,7 @@ module ActiveRecord end def configure_connection - @connection.query_options.merge!(as: :array) + @connection.query_options[:as] = :array super end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/column.rb b/activerecord/lib/active_record/connection_adapters/postgresql/column.rb index 3ccc7271ab..ef98d2b37a 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/column.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/column.rb @@ -2,42 +2,21 @@ module ActiveRecord module ConnectionAdapters - # PostgreSQL-specific extensions to column definitions in a table. - class PostgreSQLColumn < Column #:nodoc: - delegate :array, :oid, :fmod, to: :sql_type_metadata - alias :array? :array - - def initialize(*, max_identifier_length: 63, **) - super - @max_identifier_length = max_identifier_length - end - - def serial? - return unless default_function - - if %r{\Anextval\('"?(?<sequence_name>.+_(?<suffix>seq\d*))"?'::regclass\)\z} =~ default_function - sequence_name_from_parts(table_name, name, suffix) == sequence_name + module PostgreSQL + class Column < ConnectionAdapters::Column # :nodoc: + delegate :array, :oid, :fmod, to: :sql_type_metadata + alias :array? :array + + def initialize(*, serial: nil, **) + super + @serial = serial end - end - private - attr_reader :max_identifier_length - - def sequence_name_from_parts(table_name, column_name, suffix) - over_length = [table_name, column_name, suffix].map(&:length).sum + 2 - max_identifier_length - - if over_length > 0 - column_name_length = [(max_identifier_length - suffix.length - 2) / 2, column_name.length].min - over_length -= column_name.length - column_name_length - column_name = column_name[0, column_name_length - [over_length, 0].min] - end - - if over_length > 0 - table_name = table_name[0, table_name.length - over_length] - end - - "#{table_name}_#{column_name}_#{suffix}" + def serial? + @serial end + end end + PostgreSQLColumn = PostgreSQL::Column # :nodoc: end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb index 8db2a645af..d872bd662f 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb @@ -58,6 +58,8 @@ module ActiveRecord # Queries the database and returns the results in an Array-like object def query(sql, name = nil) #:nodoc: + materialize_transactions + log(sql, name) do ActiveSupport::Dependencies.interlock.permit_concurrent_loads do result_as_array @connection.async_exec(sql) @@ -65,11 +67,24 @@ module ActiveRecord end end + READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp(:begin, :commit, :explain, :select, :set, :show, :release, :savepoint, :rollback) # :nodoc: + private_constant :READ_QUERY + + def write_query?(sql) # :nodoc: + !READ_QUERY.match?(sql) + end + # Executes an SQL statement, returning a PG::Result object on success # or raising a PG::Error exception otherwise. # Note: the PG::Result object is manually memory managed; if you don't # need it specifically, you may want consider the <tt>exec_query</tt> wrapper. def execute(sql, name = nil) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + + materialize_transactions + log(sql, name) do ActiveSupport::Dependencies.interlock.permit_concurrent_loads do @connection.async_exec(sql) @@ -95,7 +110,7 @@ module ActiveRecord end alias :exec_update :exec_delete - def sql_for_insert(sql, pk, id_value, sequence_name, binds) # :nodoc: + def sql_for_insert(sql, pk, binds) # :nodoc: if pk.nil? # Extract the table from the insert sql. Yuck. table_ref = extract_table_ref_from_insert_sql(sql) @@ -149,6 +164,10 @@ module ActiveRecord end private + def build_truncate_statements(*table_names) + "TRUNCATE TABLE #{table_names.map(&method(:quote_table_name)).join(", ")}" + end + # Returns the current ID of a table's sequence. def last_insert_id_result(sequence_name) exec_query("SELECT currval(#{quote(sequence_name)})", "SQL") diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb index d6852082ac..b1dfbde86e 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb @@ -5,7 +5,7 @@ module ActiveRecord module PostgreSQL module OID # :nodoc: class Array < Type::Value # :nodoc: - include Type::Helpers::Mutable + include ActiveModel::Type::Helpers::Mutable Data = Struct.new(:encoder, :values) # :nodoc: @@ -33,7 +33,13 @@ module ActiveRecord def cast(value) if value.is_a?(::String) - value = @pg_decoder.decode(value) + value = begin + @pg_decoder.decode(value) + rescue TypeError + # malformed array string is treated as [], will raise in PG 2.0 gem + # this keeps a consistent implementation + [] + end end type_cast_array(value, :cast) end @@ -66,6 +72,10 @@ module ActiveRecord deserialize(raw_old_value) != new_value end + def force_equality?(value) + value.is_a?(::Array) + end + private def type_cast_array(value, method) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/hstore.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/hstore.rb index aabe83b85d..7b42677101 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/hstore.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/hstore.rb @@ -5,7 +5,7 @@ module ActiveRecord module PostgreSQL module OID # :nodoc: class Hstore < Type::Value # :nodoc: - include Type::Helpers::Mutable + include ActiveModel::Type::Helpers::Mutable def type :hstore diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/legacy_point.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/legacy_point.rb index 7b057a8452..7f6adc351c 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/legacy_point.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/legacy_point.rb @@ -5,7 +5,7 @@ module ActiveRecord module PostgreSQL module OID # :nodoc: class LegacyPoint < Type::Value # :nodoc: - include Type::Helpers::Mutable + include ActiveModel::Type::Helpers::Mutable def type :point diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/point.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/point.rb index 02a9c506f6..8c74cecc4d 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/point.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/point.rb @@ -7,7 +7,7 @@ module ActiveRecord module PostgreSQL module OID # :nodoc: class Point < Type::Value # :nodoc: - include Type::Helpers::Mutable + include ActiveModel::Type::Helpers::Mutable def type :point diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb index 6edb7cfd3c..aa7701e038 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb @@ -53,6 +53,10 @@ module ActiveRecord ::Range.new(new_begin, new_end, value.exclude_end?) end + def force_equality?(value) + value.is_a?(::Range) + end + private def type_cast_single(value) @@ -60,7 +64,7 @@ module ActiveRecord end def type_cast_single_for_database(value) - infinity?(value) ? value : @subtype.serialize(value) + infinity?(value) ? value : @subtype.serialize(@subtype.cast(value)) end def extract_bounds(value) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/type_map_initializer.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/type_map_initializer.rb index 231278c184..203087bc36 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/type_map_initializer.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/type_map_initializer.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "active_support/core_ext/array/extract" + module ActiveRecord module ConnectionAdapters module PostgreSQL @@ -16,12 +18,12 @@ module ActiveRecord def run(records) nodes = records.reject { |row| @store.key? row["oid"].to_i } - mapped, nodes = nodes.partition { |row| @store.key? row["typname"] } - ranges, nodes = nodes.partition { |row| row["typtype"] == "r".freeze } - enums, nodes = nodes.partition { |row| row["typtype"] == "e".freeze } - domains, nodes = nodes.partition { |row| row["typtype"] == "d".freeze } - arrays, nodes = nodes.partition { |row| row["typinput"] == "array_in".freeze } - composites, nodes = nodes.partition { |row| row["typelem"].to_i != 0 } + mapped = nodes.extract! { |row| @store.key? row["typname"] } + ranges = nodes.extract! { |row| row["typtype"] == "r" } + enums = nodes.extract! { |row| row["typtype"] == "e" } + domains = nodes.extract! { |row| row["typtype"] == "d" } + arrays = nodes.extract! { |row| row["typinput"] == "array_in" } + composites = nodes.extract! { |row| row["typelem"].to_i != 0 } mapped.each { |row| register_mapped_type(row) } enums.each { |row| register_enum_type(row) } @@ -34,7 +36,7 @@ module ActiveRecord def query_conditions_for_initial_load known_type_names = @store.keys.map { |n| "'#{n}'" } known_type_types = %w('r' 'e' 'd') - <<-SQL % [known_type_names.join(", "), known_type_types.join(", ")] + <<~SQL % [known_type_names.join(", "), known_type_types.join(", ")] WHERE t.typname IN (%s) OR t.typtype IN (%s) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb index bc9b8dbfcf..28abdbd073 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/uuid.rb @@ -13,9 +13,12 @@ module ActiveRecord :uuid end - def cast(value) - value.to_s[ACCEPTABLE_UUID, 0] - end + private + + def cast_value(value) + casted = value.to_s + casted if casted.match?(ACCEPTABLE_UUID) + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb b/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb index e75202b0be..d40e0ef1f0 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb @@ -93,11 +93,11 @@ module ActiveRecord elsif value.hex? "X'#{value}'" end - when Float - if value.infinite? || value.nan? - "'#{value}'" - else + when Numeric + if value.finite? super + else + "'#{value}'" end when OID::Array::Data _quote(encode_array(value)) @@ -138,7 +138,7 @@ module ActiveRecord end def encode_range(range) - "[#{type_cast_range_value(range.first)},#{type_cast_range_value(range.last)}#{range.exclude_end? ? ')' : ']'}" + "[#{type_cast_range_value(range.begin)},#{type_cast_range_value(range.end)}#{range.exclude_end? ? ')' : ']'}" end def determine_encoding_of_strings_in_array(value) 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 8e381a92cf..84dd28907b 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb @@ -17,12 +17,59 @@ module ActiveRecord "VALIDATE CONSTRAINT #{quote_column_name(name)}" end + def visit_ChangeColumnDefinition(o) + column = o.column + column.sql_type = type_to_sql(column.type, column.options) + quoted_column_name = quote_column_name(o.name) + + change_column_sql = +"ALTER COLUMN #{quoted_column_name} TYPE #{column.sql_type}" + + options = column_options(column) + + if options[:collation] + change_column_sql << " COLLATE \"#{options[:collation]}\"" + end + + if options[:using] + change_column_sql << " USING #{options[:using]}" + elsif options[:cast_as] + cast_as_type = type_to_sql(options[:cast_as], options) + change_column_sql << " USING CAST(#{quoted_column_name} AS #{cast_as_type})" + end + + if options.key?(:default) + if options[:default].nil? + change_column_sql << ", ALTER COLUMN #{quoted_column_name} DROP DEFAULT" + else + quoted_default = quote_default_expression(options[:default], column) + change_column_sql << ", ALTER COLUMN #{quoted_column_name} SET DEFAULT #{quoted_default}" + end + end + + if options.key?(:null) + change_column_sql << ", ALTER COLUMN #{quoted_column_name} #{options[:null] ? 'DROP' : 'SET'} NOT NULL" + end + + change_column_sql + end + def add_column_options!(sql, options) if options[:collation] sql << " COLLATE \"#{options[:collation]}\"" end super end + + # Returns any SQL string to go between CREATE and TABLE. May be nil. + def table_modifier_in_create(o) + # A table cannot be both TEMPORARY and UNLOGGED, since all TEMPORARY + # tables are already UNLOGGED. + if o.temporary + " TEMPORARY" + elsif o.unlogged + " UNLOGGED" + end + end end end end 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 6047217fcd..3bb7c52899 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb @@ -4,6 +4,8 @@ module ActiveRecord module ConnectionAdapters module PostgreSQL module ColumnMethods + extend ActiveSupport::Concern + # Defines the primary key field. # Use of the native PostgreSQL UUID type is supported, and can be used # by defining your tables as such: @@ -13,10 +15,10 @@ module ActiveRecord # t.timestamps # end # - # By default, this will use the +gen_random_uuid()+ function from the + # By default, this will use the <tt>gen_random_uuid()</tt> function from the # +pgcrypto+ extension. As that extension is only available in # PostgreSQL 9.4+, for earlier versions an explicit default can be set - # to use +uuid_generate_v4()+ from the +uuid-ossp+ extension instead: + # to use <tt>uuid_generate_v4()</tt> from the +uuid-ossp+ extension instead: # # create_table :stuffs, id: false do |t| # t.primary_key :id, :uuid, default: "uuid_generate_v4()" @@ -51,130 +53,144 @@ module ActiveRecord super end - def bigserial(*args, **options) - args.each { |name| column(name, :bigserial, options) } - end + ## + # :method: bigserial + # :call-seq: bigserial(*names, **options) - def bit(*args, **options) - args.each { |name| column(name, :bit, options) } - end + ## + # :method: bit + # :call-seq: bit(*names, **options) - def bit_varying(*args, **options) - args.each { |name| column(name, :bit_varying, options) } - end + ## + # :method: bit_varying + # :call-seq: bit_varying(*names, **options) - def cidr(*args, **options) - args.each { |name| column(name, :cidr, options) } - end + ## + # :method: cidr + # :call-seq: cidr(*names, **options) - def citext(*args, **options) - args.each { |name| column(name, :citext, options) } - end + ## + # :method: citext + # :call-seq: citext(*names, **options) - def daterange(*args, **options) - args.each { |name| column(name, :daterange, options) } - end + ## + # :method: daterange + # :call-seq: daterange(*names, **options) - def hstore(*args, **options) - args.each { |name| column(name, :hstore, options) } - end + ## + # :method: hstore + # :call-seq: hstore(*names, **options) - def inet(*args, **options) - args.each { |name| column(name, :inet, options) } - end + ## + # :method: inet + # :call-seq: inet(*names, **options) - def interval(*args, **options) - args.each { |name| column(name, :interval, options) } - end + ## + # :method: interval + # :call-seq: interval(*names, **options) - def int4range(*args, **options) - args.each { |name| column(name, :int4range, options) } - end + ## + # :method: int4range + # :call-seq: int4range(*names, **options) - def int8range(*args, **options) - args.each { |name| column(name, :int8range, options) } - end + ## + # :method: int8range + # :call-seq: int8range(*names, **options) - def jsonb(*args, **options) - args.each { |name| column(name, :jsonb, options) } - end + ## + # :method: jsonb + # :call-seq: jsonb(*names, **options) - def ltree(*args, **options) - args.each { |name| column(name, :ltree, options) } - end + ## + # :method: ltree + # :call-seq: ltree(*names, **options) - def macaddr(*args, **options) - args.each { |name| column(name, :macaddr, options) } - end + ## + # :method: macaddr + # :call-seq: macaddr(*names, **options) - def money(*args, **options) - args.each { |name| column(name, :money, options) } - end + ## + # :method: money + # :call-seq: money(*names, **options) - def numrange(*args, **options) - args.each { |name| column(name, :numrange, options) } - end + ## + # :method: numrange + # :call-seq: numrange(*names, **options) - def oid(*args, **options) - args.each { |name| column(name, :oid, options) } - end + ## + # :method: oid + # :call-seq: oid(*names, **options) - def point(*args, **options) - args.each { |name| column(name, :point, options) } - end + ## + # :method: point + # :call-seq: point(*names, **options) - def line(*args, **options) - args.each { |name| column(name, :line, options) } - end + ## + # :method: line + # :call-seq: line(*names, **options) - def lseg(*args, **options) - args.each { |name| column(name, :lseg, options) } - end + ## + # :method: lseg + # :call-seq: lseg(*names, **options) - def box(*args, **options) - args.each { |name| column(name, :box, options) } - end + ## + # :method: box + # :call-seq: box(*names, **options) - def path(*args, **options) - args.each { |name| column(name, :path, options) } - end + ## + # :method: path + # :call-seq: path(*names, **options) - def polygon(*args, **options) - args.each { |name| column(name, :polygon, options) } - end + ## + # :method: polygon + # :call-seq: polygon(*names, **options) - def circle(*args, **options) - args.each { |name| column(name, :circle, options) } - end + ## + # :method: circle + # :call-seq: circle(*names, **options) - def serial(*args, **options) - args.each { |name| column(name, :serial, options) } - end + ## + # :method: serial + # :call-seq: serial(*names, **options) - def tsrange(*args, **options) - args.each { |name| column(name, :tsrange, options) } - end + ## + # :method: tsrange + # :call-seq: tsrange(*names, **options) - def tstzrange(*args, **options) - args.each { |name| column(name, :tstzrange, options) } - end + ## + # :method: tstzrange + # :call-seq: tstzrange(*names, **options) - def tsvector(*args, **options) - args.each { |name| column(name, :tsvector, options) } - end + ## + # :method: tsvector + # :call-seq: tsvector(*names, **options) - def uuid(*args, **options) - args.each { |name| column(name, :uuid, options) } - end + ## + # :method: uuid + # :call-seq: uuid(*names, **options) + + ## + # :method: xml + # :call-seq: xml(*names, **options) - def xml(*args, **options) - args.each { |name| column(name, :xml, options) } + included do + define_column_methods :bigserial, :bit, :bit_varying, :cidr, :citext, :daterange, + :hstore, :inet, :interval, :int4range, :int8range, :jsonb, :ltree, :macaddr, + :money, :numrange, :oid, :point, :line, :lseg, :box, :path, :polygon, :circle, + :serial, :tsrange, :tstzrange, :tsvector, :uuid, :xml end end class TableDefinition < ActiveRecord::ConnectionAdapters::TableDefinition include ColumnMethods + attr_reader :unlogged + + def initialize(*) + super + @unlogged = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables + end + private def integer_like_primary_key_type(type, options) if type == :bigint || options[:limit] == 8 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 e20e5f2914..c412d1f34c 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb @@ -22,8 +22,8 @@ module ActiveRecord def create_database(name, options = {}) options = { encoding: "utf8" }.merge!(options.symbolize_keys) - option_string = options.inject("") do |memo, (key, value)| - memo += case key + option_string = options.each_with_object(+"") do |(key, value), memo| + memo << case key when :owner " OWNER = \"#{value}\"" when :template @@ -68,7 +68,7 @@ module ActiveRecord table = quoted_scope(table_name) index = quoted_scope(index_name) - query_value(<<-SQL, "SCHEMA").to_i > 0 + query_value(<<~SQL, "SCHEMA").to_i > 0 SELECT COUNT(*) FROM pg_class t INNER JOIN pg_index d ON t.oid = d.indrelid @@ -85,7 +85,7 @@ module ActiveRecord def indexes(table_name) # :nodoc: scope = quoted_scope(table_name) - result = query(<<-SQL, "SCHEMA") + 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 FROM pg_class t @@ -124,7 +124,7 @@ module ActiveRecord # add info on sort order (only desc order is explicitly specified, asc is the default) # and non-default opclasses - expressions.scan(/(?<column>\w+)\s?(?<opclass>\w+_ops)?\s?(?<desc>DESC)?\s?(?<nulls>NULLS (?:FIRST|LAST))?/).each do |column, opclass, desc, nulls| + expressions.scan(/(?<column>\w+)"?\s?(?<opclass>\w+_ops)?\s?(?<desc>DESC)?\s?(?<nulls>NULLS (?:FIRST|LAST))?/).each do |column, opclass, desc, nulls| opclasses[column] = opclass.to_sym if opclass if nulls orders[column] = [desc, nulls].compact.join(" ") @@ -196,7 +196,7 @@ module ActiveRecord # Returns an array of schema names. def schema_names - query_values(<<-SQL, "SCHEMA") + query_values(<<~SQL, "SCHEMA") SELECT nspname FROM pg_namespace WHERE nspname !~ '^pg_.*' @@ -287,7 +287,7 @@ module ActiveRecord quoted_sequence = quote_table_name(sequence) max_pk = query_value("SELECT MAX(#{quote_column_name pk}) FROM #{quote_table_name(table)}", "SCHEMA") if max_pk.nil? - if postgresql_version >= 100000 + if database_version >= 100000 minvalue = query_value("SELECT seqmin FROM pg_sequence WHERE seqrelid = #{quote(quoted_sequence)}::regclass", "SCHEMA") else minvalue = query_value("SELECT min_value FROM #{quoted_sequence}", "SCHEMA") @@ -302,7 +302,7 @@ module ActiveRecord def pk_and_sequence_for(table) #:nodoc: # First try looking for a sequence with a dependency on the # given table's primary key. - result = query(<<-end_sql, "SCHEMA")[0] + result = query(<<~SQL, "SCHEMA")[0] SELECT attr.attname, nsp.nspname, seq.relname FROM pg_class seq, pg_attribute attr, @@ -319,10 +319,10 @@ module ActiveRecord AND cons.contype = 'p' AND dep.classid = 'pg_class'::regclass AND dep.refobjid = #{quote(quote_table_name(table))}::regclass - end_sql + SQL if result.nil? || result.empty? - result = query(<<-end_sql, "SCHEMA")[0] + result = query(<<~SQL, "SCHEMA")[0] SELECT attr.attname, nsp.nspname, CASE WHEN pg_get_expr(def.adbin, def.adrelid) !~* 'nextval' THEN NULL @@ -339,7 +339,7 @@ module ActiveRecord WHERE t.oid = #{quote(quote_table_name(table))}::regclass AND cons.contype = 'p' AND pg_get_expr(def.adbin, def.adrelid) ~* 'nextval|uuid_generate' - end_sql + SQL end pk = result.shift @@ -548,21 +548,21 @@ module ActiveRecord # The hard limit is 1GB, because of a 32-bit size field, and TOAST. case limit when nil, 0..0x3fffffff; super(type) - else raise(ActiveRecordError, "No binary type has byte size #{limit}.") + else raise ArgumentError, "No binary type has byte size #{limit}. The limit on binary can be at most 1GB - 1byte." end when "text" # PostgreSQL doesn't support limits on text columns. # The hard limit is 1GB, according to section 8.3 in the manual. case limit when nil, 0..0x3fffffff; super(type) - else raise(ActiveRecordError, "The limit on text can be at most 1GB - 1byte.") + else raise ArgumentError, "No text type has byte size #{limit}. The limit on text can be at most 1GB - 1byte." end when "integer" case limit when 1, 2; "smallint" when nil, 3, 4; "integer" when 5..8; "bigint" - else raise(ActiveRecordError, "No integer type has byte size #{limit}. Use a numeric with scale 0 instead.") + else raise ArgumentError, "No integer type has byte size #{limit}. Use a numeric with scale 0 instead." end else super @@ -623,10 +623,10 @@ module ActiveRecord # 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 = {}) + def validate_foreign_key(from_table, to_table = nil, **options) return unless supports_validate_constraints? - fk_name_to_validate = foreign_key_for!(from_table, options_or_to_table).name + fk_name_to_validate = foreign_key_for!(from_table, to_table: to_table, **options).name validate_constraint from_table, fk_name_to_validate end @@ -637,7 +637,7 @@ module ActiveRecord end def create_table_definition(*args) - PostgreSQL::TableDefinition.new(*args) + PostgreSQL::TableDefinition.new(self, *args) end def create_alter_table(name) @@ -650,16 +650,19 @@ module ActiveRecord default_value = extract_value_from_default(default) default_function = extract_default_function(default_value, default) - PostgreSQLColumn.new( + if match = default_function&.match(/\Anextval\('"?(?<sequence_name>.+_(?<suffix>seq\d*))"?'::regclass\)\z/) + serial = sequence_name_from_parts(table_name, column_name, match[:suffix]) == match[:sequence_name] + end + + PostgreSQL::Column.new( column_name, default_value, type_metadata, !notnull, - table_name, default_function, - collation, + collation: collation, comment: comment.presence, - max_identifier_length: max_identifier_length + serial: serial ) end @@ -675,6 +678,22 @@ module ActiveRecord PostgreSQLTypeMetadata.new(simple_type, oid: oid, fmod: fmod) end + def sequence_name_from_parts(table_name, column_name, suffix) + over_length = [table_name, column_name, suffix].sum(&:length) + 2 - max_identifier_length + + if over_length > 0 + column_name_length = [(max_identifier_length - suffix.length - 2) / 2, column_name.length].min + over_length -= column_name.length - column_name_length + column_name = column_name[0, column_name_length - [over_length, 0].min] + end + + if over_length > 0 + table_name = table_name[0, table_name.length - over_length] + end + + "#{table_name}_#{column_name}_#{suffix}" + end + def extract_foreign_key_action(specifier) case specifier when "c"; :cascade @@ -683,34 +702,20 @@ 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 + def add_column_for_alter(table_name, column_name, type, options = {}) + return super unless options.key?(:comment) + [super, Proc.new { change_column_comment(table_name, column_name, options[:comment]) }] end def change_column_for_alter(table_name, column_name, type, options = {}) - sqls = [change_column_sql(table_name, column_name, type, options)] - 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) + td = create_table_definition(table_name) + cd = td.new_column_definition(column_name, type, options) + sqls = [schema_creation.accept(ChangeColumnDefinition.new(cd, column_name))] sqls << Proc.new { change_column_comment(table_name, column_name, options[:comment]) } if options.key?(:comment) sqls end - - # Changes the default value of a table column. - def change_column_default_for_alter(table_name, column_name, default_or_changes) # :nodoc: + def change_column_default_for_alter(table_name, column_name, default_or_changes) column = column_for(table_name, column_name) return unless column @@ -725,11 +730,17 @@ module ActiveRecord 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" + def change_column_null_for_alter(table_name, column_name, null, default = nil) + "ALTER COLUMN #{quote_column_name(column_name)} #{null ? 'DROP' : 'SET'} NOT NULL" end def add_timestamps_for_alter(table_name, options = {}) + options[:null] = false if options[:null].nil? + + if !options.key?(:precision) && supports_datetime_with_precision? + options[:precision] = 6 + end + [add_column_for_alter(table_name, :created_at, :datetime, options), add_column_for_alter(table_name, :updated_at, :datetime, options)] end @@ -751,9 +762,9 @@ module ActiveRecord def data_source_sql(name = nil, type: nil) scope = quoted_scope(name, type: type) - scope[:type] ||= "'r','v','m','f'" # (r)elation/table, (v)iew, (m)aterialized view, (f)oreign table + scope[:type] ||= "'r','v','m','p','f'" # (r)elation/table, (v)iew, (m)aterialized view, (p)artitioned table, (f)oreign table - sql = "SELECT c.relname FROM pg_class c LEFT JOIN pg_namespace n ON n.oid = c.relnamespace".dup + sql = +"SELECT c.relname FROM pg_class c LEFT JOIN pg_namespace n ON n.oid = c.relnamespace" sql << " WHERE n.nspname = #{scope[:schema]}" sql << " AND c.relname = #{scope[:name]}" if scope[:name] sql << " AND c.relkind IN (#{scope[:type]})" @@ -765,7 +776,7 @@ module ActiveRecord type = \ case type when "BASE TABLE" - "'r'" + "'r','p'" when "VIEW" "'v','m'" when "FOREIGN TABLE" diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/type_metadata.rb b/activerecord/lib/active_record/connection_adapters/postgresql/type_metadata.rb index b252a76caa..403b3ead98 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/type_metadata.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/type_metadata.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module ActiveRecord + # :stopdoc: module ConnectionAdapters class PostgreSQLTypeMetadata < DelegateClass(SqlTypeMetadata) undef to_yaml if method_defined?(:to_yaml) @@ -16,24 +17,25 @@ module ActiveRecord end def sql_type - super.gsub(/\[\]$/, "".freeze) + super.gsub(/\[\]$/, "") end def ==(other) other.is_a?(PostgreSQLTypeMetadata) && - attributes_for_hash == other.attributes_for_hash + __getobj__ == other.__getobj__ && + oid == other.oid && + fmod == other.fmod && + array == other.array end alias eql? == def hash - attributes_for_hash.hash + PostgreSQLTypeMetadata.hash ^ + __getobj__.hash ^ + oid.hash ^ + fmod.hash ^ + array.hash end - - protected - - def attributes_for_hash - [self.class, @type_metadata, oid, fmod] - end end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/utils.rb b/activerecord/lib/active_record/connection_adapters/postgresql/utils.rb index bfd300723d..f2f4701500 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/utils.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/utils.rb @@ -68,7 +68,7 @@ module ActiveRecord # * <tt>"schema_name".table_name</tt> # * <tt>"schema.name"."table name"</tt> def extract_schema_qualified_name(string) - schema, table = string.scan(/[^".\s]+|"[^"]*"/) + schema, table = string.scan(/[^".]+|"[^"]*"/) if table.nil? table = schema schema = nil diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index fdf6f75108..91318a0af1 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -4,6 +4,14 @@ gem "pg", ">= 0.18", "< 2.0" require "pg" +# Use async_exec instead of exec_params on pg versions before 1.1 +class ::PG::Connection # :nodoc: + unless self.public_method_defined?(:async_exec_params) + remove_method :exec_params + alias exec_params async_exec + end +end + require "active_record/connection_adapters/abstract_adapter" require "active_record/connection_adapters/statement_pool" require "active_record/connection_adapters/postgresql/column" @@ -35,9 +43,14 @@ module ActiveRecord valid_conn_param_keys = PG::Connection.conndefaults_hash.keys + [:requiressl] conn_params.slice!(*valid_conn_param_keys) - # The postgres drivers don't allow the creation of an unconnected PG::Connection object, - # so just pass a nil connection object for the time being. - ConnectionAdapters::PostgreSQLAdapter.new(nil, logger, conn_params, config) + conn = PG.connect(conn_params) + ConnectionAdapters::PostgreSQLAdapter.new(conn, logger, conn_params, config) + rescue ::PG::Error => error + if error.message.include?("does not exist") + raise ActiveRecord::NoDatabaseError + else + raise + end end end @@ -70,7 +83,20 @@ module ActiveRecord # In addition, default connection parameters of libpq can be set per environment variables. # See https://www.postgresql.org/docs/current/static/libpq-envars.html . class PostgreSQLAdapter < AbstractAdapter - ADAPTER_NAME = "PostgreSQL".freeze + ADAPTER_NAME = "PostgreSQL" + + ## + # :singleton-method: + # PostgreSQL allows the creation of "unlogged" tables, which do not record + # data in the PostgreSQL Write-Ahead Log. This can make the tables faster, + # but significantly increases the risk of data loss if the database + # crashes. As a result, this should not be used in production + # environments. If you would like all created tables to be unlogged in + # the test environment you can add the following line to your test.rb + # file: + # + # ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = true + class_attribute :create_unlogged_tables, default: false NATIVE_DATABASE_TYPES = { primary_key: "bigserial primary key", @@ -159,7 +185,7 @@ module ActiveRecord end def supports_json? - postgresql_version >= 90200 + true end def supports_comments? @@ -170,6 +196,17 @@ module ActiveRecord true end + def supports_insert_returning? + true + end + + def supports_insert_on_conflict? + database_version >= 90500 + end + alias supports_insert_on_duplicate_skip? supports_insert_on_conflict? + alias supports_insert_on_duplicate_update? supports_insert_on_conflict? + alias supports_insert_conflict_target? supports_insert_on_conflict? + def index_algorithms { concurrently: "CONCURRENTLY" } end @@ -212,15 +249,8 @@ module ActiveRecord @local_tz = nil @max_identifier_length = nil - connect + configure_connection add_pg_encoders - @statements = StatementPool.new @connection, - self.class.type_cast_config_to_integer(config[:statement_limit]) - - if postgresql_version < 90100 - raise "Your version of PostgreSQL (#{postgresql_version}) is too old. Active Record supports PostgreSQL >= 9.1." - end - add_pg_decoders @type_map = Type::HashLookupTypeMap.new @@ -229,17 +259,6 @@ module ActiveRecord @use_insert_returning = @config.key?(:insert_returning) ? self.class.type_cast_config_to_boolean(@config[:insert_returning]) : true end - # Clears the prepared statements cache. - def clear_cache! - @lock.synchronize do - @statements.clear - end - end - - def truncate(table_name, name = nil) - exec_query "TRUNCATE TABLE #{quote_table_name(table_name)}", name, [] - end - # Is this connection alive and ready for queries? def active? @lock.synchronize do @@ -256,6 +275,8 @@ module ActiveRecord super @connection.reset configure_connection + rescue PG::ConnectionBad + connect end end @@ -310,20 +331,31 @@ module ActiveRecord end def supports_ranges? - # Range datatypes weren't introduced until PostgreSQL 9.2 - postgresql_version >= 90200 + true end + deprecate :supports_ranges? def supports_materialized_views? - postgresql_version >= 90300 + true end def supports_foreign_tables? - postgresql_version >= 90300 + true end def supports_pgcrypto_uuid? - postgresql_version >= 90400 + database_version >= 90400 + end + + def supports_optimizer_hints? + unless defined?(@has_pg_hint_plan) + @has_pg_hint_plan = extension_available?("pg_hint_plan") + end + @has_pg_hint_plan + end + + def supports_lazy_transactions? + true end def get_advisory_lock(lock_id) # :nodoc: @@ -352,9 +384,12 @@ module ActiveRecord } end + def extension_available?(name) + query_value("SELECT true FROM pg_available_extensions WHERE name = #{quote(name)}", "SCHEMA") + end + def extension_enabled?(name) - res = exec_query("SELECT EXISTS(SELECT * FROM pg_available_extensions WHERE name = '#{name}' AND installed_version IS NOT NULL) as enabled", "SCHEMA") - res.cast_values.first + query_value("SELECT installed_version IS NOT NULL FROM pg_available_extensions WHERE name = #{quote(name)}", "SCHEMA") end def extensions @@ -365,8 +400,6 @@ module ActiveRecord def max_identifier_length @max_identifier_length ||= query_value("SHOW max_identifier_length", "SCHEMA").to_i end - alias table_alias_length max_identifier_length - alias index_name_length max_identifier_length # Set the authorized user for this session def session_auth=(user) @@ -389,15 +422,37 @@ module ActiveRecord } # Returns the version of the connected PostgreSQL server. - def postgresql_version + def get_database_version # :nodoc: @connection.server_version end + alias :postgresql_version :database_version def default_index_type?(index) # :nodoc: index.using == :btree || super end + def build_insert_sql(insert) # :nodoc: + sql = +"INSERT #{insert.into} #{insert.values_list}" + + if insert.skip_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO NOTHING" + elsif insert.update_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO UPDATE SET " + sql << insert.updatable_columns.map { |column| "#{column}=excluded.#{column}" }.join(",") + end + + sql << " RETURNING #{insert.returning}" if insert.returning + sql + end + + def check_version # :nodoc: + if database_version < 90300 + raise "Your version of PostgreSQL (#{database_version}) is too old. Active Record supports PostgreSQL >= 9.3." + end + end + private + # See https://www.postgresql.org/docs/current/static/errcodes-appendix.html VALUE_LIMIT_VIOLATION = "22001" NUMERIC_VALUE_OUT_OF_RANGE = "22003" @@ -409,34 +464,34 @@ module ActiveRecord LOCK_NOT_AVAILABLE = "55P03" QUERY_CANCELED = "57014" - def translate_exception(exception, message) + def translate_exception(exception, message:, sql:, binds:) return exception unless exception.respond_to?(:result) case exception.result.try(:error_field, PG::PG_DIAG_SQLSTATE) when UNIQUE_VIOLATION - RecordNotUnique.new(message) + RecordNotUnique.new(message, sql: sql, binds: binds) when FOREIGN_KEY_VIOLATION - InvalidForeignKey.new(message) + InvalidForeignKey.new(message, sql: sql, binds: binds) when VALUE_LIMIT_VIOLATION - ValueTooLong.new(message) + ValueTooLong.new(message, sql: sql, binds: binds) when NUMERIC_VALUE_OUT_OF_RANGE - RangeError.new(message) + RangeError.new(message, sql: sql, binds: binds) when NOT_NULL_VIOLATION - NotNullViolation.new(message) + NotNullViolation.new(message, sql: sql, binds: binds) when SERIALIZATION_FAILURE - SerializationFailure.new(message) + SerializationFailure.new(message, sql: sql, binds: binds) when DEADLOCK_DETECTED - Deadlocked.new(message) + Deadlocked.new(message, sql: sql, binds: binds) when LOCK_NOT_AVAILABLE - LockWaitTimeout.new(message) + LockWaitTimeout.new(message, sql: sql, binds: binds) when QUERY_CANCELED - QueryCanceled.new(message) + QueryCanceled.new(message, sql: sql, binds: binds) else super end end - def get_oid_type(oid, fmod, column_name, sql_type = "".freeze) + def get_oid_type(oid, fmod, column_name, sql_type = "") if !type_map.key?(oid) load_additional_types([oid]) end @@ -525,13 +580,13 @@ module ActiveRecord # Quoted types when /\A[\(B]?'(.*)'.*::"?([\w. ]+)"?(?:\[\])?\z/m # The default 'now'::date is CURRENT_DATE - if $1 == "now".freeze && $2 == "date".freeze + if $1 == "now" && $2 == "date" nil else - $1.gsub("''".freeze, "'".freeze) + $1.gsub("''", "'") end # Boolean types - when "true".freeze, "false".freeze + when "true", "false" default # Numeric types when /\A\(?(-?\d+(\.\d*)?)\)?(::bigint)?\z/ @@ -557,18 +612,11 @@ module ActiveRecord def load_additional_types(oids = nil) initializer = OID::TypeMapInitializer.new(type_map) - if supports_ranges? - query = <<-SQL - SELECT t.oid, t.typname, t.typelem, t.typdelim, t.typinput, r.rngsubtype, t.typtype, t.typbasetype - FROM pg_type as t - LEFT JOIN pg_range as r ON oid = rngtypid - SQL - else - query = <<-SQL - SELECT t.oid, t.typname, t.typelem, t.typdelim, t.typinput, t.typtype, t.typbasetype - FROM pg_type as t - SQL - end + query = <<~SQL + SELECT t.oid, t.typname, t.typelem, t.typdelim, t.typinput, r.rngsubtype, t.typtype, t.typbasetype + FROM pg_type as t + LEFT JOIN pg_range as r ON oid = rngtypid + SQL if oids query += "WHERE t.oid::integer IN (%s)" % oids.join(", ") @@ -584,6 +632,10 @@ module ActiveRecord FEATURE_NOT_SUPPORTED = "0A000" #:nodoc: def execute_and_clear(sql, name, binds, prepare: false) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + if without_prepared_statement?(binds) result = exec_no_cache(sql, name, []) elsif !prepare @@ -597,16 +649,25 @@ module ActiveRecord end def exec_no_cache(sql, name, binds) + materialize_transactions + + # make sure we carry over any changes to ActiveRecord::Base.default_timezone that have been + # made since we established the connection + update_typemap_for_default_timezone + type_casted_binds = type_casted_binds(binds) log(sql, name, binds, type_casted_binds) do ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - @connection.async_exec(sql, type_casted_binds) + @connection.exec_params(sql, type_casted_binds) end end end def exec_cache(sql, name, binds) - stmt_key = prepare_statement(sql) + materialize_transactions + update_typemap_for_default_timezone + + stmt_key = prepare_statement(sql, binds) type_casted_binds = type_casted_binds(binds) log(sql, name, binds, type_casted_binds, stmt_key) do @@ -639,7 +700,7 @@ module ActiveRecord # # Check here for more details: # https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/utils/cache/plancache.c#l573 - CACHED_PLAN_HEURISTIC = "cached plan must not change result type".freeze + CACHED_PLAN_HEURISTIC = "cached plan must not change result type" def is_cached_plan_failure?(e) pgerror = e.cause code = pgerror.result.result_error_field(PG::PG_DIAG_SQLSTATE) @@ -660,7 +721,7 @@ module ActiveRecord # Prepare the statement if it hasn't been prepared, return # the statement key. - def prepare_statement(sql) + def prepare_statement(sql, binds) @lock.synchronize do sql_key = sql_key(sql) unless @statements.key? sql_key @@ -668,7 +729,7 @@ module ActiveRecord begin @connection.prepare nextkey, sql rescue => e - raise translate_exception_class(e, sql) + raise translate_exception_class(e, sql, binds) end # Clear the queue @connection.get_last_result @@ -683,12 +744,8 @@ module ActiveRecord def connect @connection = PG.connect(@connection_parameters) configure_connection - rescue ::PG::Error => error - if error.message.include?("does not exist") - raise ActiveRecord::NoDatabaseError - else - raise - end + add_pg_encoders + add_pg_decoders end # Configures the encoding, verbosity, schema search path, and time zone of the connection. @@ -746,7 +803,7 @@ module ActiveRecord # - format_type includes the column size constraint, e.g. varchar(50) # - ::regclass is a function that gives the id for a table name def column_definitions(table_name) - query(<<-end_sql, "SCHEMA") + query(<<~SQL, "SCHEMA") SELECT a.attname, format_type(a.atttypid, a.atttypmod), pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod, c.collname, col_description(a.attrelid, a.attnum) AS comment @@ -757,7 +814,7 @@ module ActiveRecord WHERE a.attrelid = #{quote(quote_table_name(table_name))}::regclass AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum - end_sql + SQL end def extract_table_ref_from_insert_sql(sql) @@ -769,10 +826,14 @@ module ActiveRecord Arel::Visitors::PostgreSQL.new(self) end + def build_statement_pool + StatementPool.new(@connection, self.class.type_cast_config_to_integer(@config[:statement_limit])) + end + def can_perform_case_insensitive_comparison_for?(column) @case_insensitive_cache ||= {} @case_insensitive_cache[column.sql_type] ||= begin - sql = <<-end_sql + sql = <<~SQL SELECT exists( SELECT * FROM pg_proc WHERE proname = 'lower' @@ -784,7 +845,7 @@ module ActiveRecord WHERE proname = 'lower' AND castsource = #{quote column.sql_type}::regtype ) - end_sql + SQL execute_and_clear(sql, "SCHEMA", []) do |result| result.getvalue(0, 0) end @@ -799,7 +860,22 @@ module ActiveRecord @connection.type_map_for_queries = map end + def update_typemap_for_default_timezone + if @default_timezone != ActiveRecord::Base.default_timezone && @timestamp_decoder + decoder_class = ActiveRecord::Base.default_timezone == :utc ? + PG::TextDecoder::TimestampUtc : + PG::TextDecoder::TimestampWithoutTimeZone + + @timestamp_decoder = decoder_class.new(@timestamp_decoder.to_h) + @connection.type_map_for_results.add_coder(@timestamp_decoder) + @default_timezone = ActiveRecord::Base.default_timezone + end + end + def add_pg_decoders + @default_timezone = nil + @timestamp_decoder = nil + coders_by_name = { "int2" => PG::TextDecoder::Integer, "int4" => PG::TextDecoder::Integer, @@ -809,8 +885,15 @@ module ActiveRecord "float8" => PG::TextDecoder::Float, "bool" => PG::TextDecoder::Boolean, } + + if defined?(PG::TextDecoder::TimestampUtc) + # Use native PG encoders available since pg-1.1 + coders_by_name["timestamp"] = PG::TextDecoder::TimestampUtc + coders_by_name["timestamptz"] = PG::TextDecoder::TimestampWithTimeZone + end + known_coder_types = coders_by_name.keys.map { |n| quote(n) } - query = <<-SQL % known_coder_types.join(", ") + query = <<~SQL % known_coder_types.join(", ") SELECT t.oid, t.typname FROM pg_type as t WHERE t.typname IN (%s) @@ -824,6 +907,10 @@ module ActiveRecord map = PG::TypeMapByOid.new coders.each { |coder| map.add_coder(coder) } @connection.type_map_for_results = map + + # extract timestamp decoder for use in update_typemap_for_default_timezone + @timestamp_decoder = coders.find { |coder| coder.name == "timestamp" } + update_typemap_for_default_timezone end def construct_coder(row, coder_class) diff --git a/activerecord/lib/active_record/connection_adapters/schema_cache.rb b/activerecord/lib/active_record/connection_adapters/schema_cache.rb index c29cf1f9a1..dbfe1e4a34 100644 --- a/activerecord/lib/active_record/connection_adapters/schema_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/schema_cache.rb @@ -13,6 +13,7 @@ module ActiveRecord @columns_hash = {} @primary_keys = {} @data_sources = {} + @indexes = {} end def initialize_dup(other) @@ -21,22 +22,27 @@ module ActiveRecord @columns_hash = @columns_hash.dup @primary_keys = @primary_keys.dup @data_sources = @data_sources.dup + @indexes = @indexes.dup end def encode_with(coder) - coder["columns"] = @columns - coder["columns_hash"] = @columns_hash - coder["primary_keys"] = @primary_keys - coder["data_sources"] = @data_sources - coder["version"] = connection.migration_context.current_version + coder["columns"] = @columns + coder["columns_hash"] = @columns_hash + coder["primary_keys"] = @primary_keys + coder["data_sources"] = @data_sources + coder["indexes"] = @indexes + coder["version"] = connection.migration_context.current_version + coder["database_version"] = database_version end def init_with(coder) - @columns = coder["columns"] - @columns_hash = coder["columns_hash"] - @primary_keys = coder["primary_keys"] - @data_sources = coder["data_sources"] - @version = coder["version"] + @columns = coder["columns"] + @columns_hash = coder["columns_hash"] + @primary_keys = coder["primary_keys"] + @data_sources = coder["data_sources"] + @indexes = coder["indexes"] || {} + @version = coder["version"] + @database_version = coder["database_version"] end def primary_keys(table_name) @@ -57,6 +63,7 @@ module ActiveRecord primary_keys(table_name) columns(table_name) columns_hash(table_name) + indexes(table_name) end end @@ -77,17 +84,32 @@ module ActiveRecord }] end + # Checks whether the columns hash is already cached for a table. + def columns_hash?(table_name) + @columns_hash.key?(table_name) + end + + def indexes(table_name) + @indexes[table_name] ||= connection.indexes(table_name) + end + + def database_version # :nodoc: + @database_version ||= connection.get_database_version + end + # Clears out internal caches def clear! @columns.clear @columns_hash.clear @primary_keys.clear @data_sources.clear + @indexes.clear @version = nil + @database_version = nil end def size - [@columns, @columns_hash, @primary_keys, @data_sources].map(&:size).inject :+ + [@columns, @columns_hash, @primary_keys, @data_sources].sum(&:size) end # Clear out internal caches for the data source +name+. @@ -96,20 +118,21 @@ module ActiveRecord @columns_hash.delete name @primary_keys.delete name @data_sources.delete name + @indexes.delete name end def marshal_dump # if we get current version during initialization, it happens stack over flow. @version = connection.migration_context.current_version - [@version, @columns, @columns_hash, @primary_keys, @data_sources] + [@version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes, database_version] end def marshal_load(array) - @version, @columns, @columns_hash, @primary_keys, @data_sources = array + @version, @columns, @columns_hash, @primary_keys, @data_sources, @indexes, @database_version = array + @indexes = @indexes || {} end private - def prepare_data_sources connection.data_sources.each { |source| @data_sources[source] = true } end diff --git a/activerecord/lib/active_record/connection_adapters/sql_type_metadata.rb b/activerecord/lib/active_record/connection_adapters/sql_type_metadata.rb index 8489bcbf1d..df28df7a7c 100644 --- a/activerecord/lib/active_record/connection_adapters/sql_type_metadata.rb +++ b/activerecord/lib/active_record/connection_adapters/sql_type_metadata.rb @@ -16,19 +16,22 @@ module ActiveRecord def ==(other) other.is_a?(SqlTypeMetadata) && - attributes_for_hash == other.attributes_for_hash + sql_type == other.sql_type && + type == other.type && + limit == other.limit && + precision == other.precision && + scale == other.scale end alias eql? == def hash - attributes_for_hash.hash + SqlTypeMetadata.hash ^ + sql_type.hash ^ + type.hash ^ + limit.hash ^ + precision.hash >> 1 ^ + scale.hash >> 2 end - - protected - - def attributes_for_hash - [self.class, sql_type, type, limit, precision, scale] - end end end end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb new file mode 100644 index 0000000000..46ce1a15b5 --- /dev/null +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module ActiveRecord + module ConnectionAdapters + module SQLite3 + module DatabaseStatements + READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp(:begin, :commit, :explain, :select, :pragma, :release, :savepoint, :rollback) # :nodoc: + private_constant :READ_QUERY + + def write_query?(sql) # :nodoc: + !READ_QUERY.match?(sql) + end + + def execute(sql, name = nil) #:nodoc: + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + + materialize_transactions + + log(sql, name) do + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + @connection.execute(sql) + end + end + end + + def exec_query(sql, name = nil, binds = [], prepare: false) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + + materialize_transactions + + type_casted_binds = type_casted_binds(binds) + + log(sql, name, binds, type_casted_binds) do + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + # Don't cache statements if they are not prepared + unless prepare + stmt = @connection.prepare(sql) + begin + cols = stmt.columns + unless without_prepared_statement?(binds) + stmt.bind_params(type_casted_binds) + end + records = stmt.to_a + ensure + stmt.close + end + else + stmt = @statements[sql] ||= @connection.prepare(sql) + cols = stmt.columns + stmt.reset! + stmt.bind_params(type_casted_binds) + records = stmt.to_a + end + + ActiveRecord::Result.new(cols, records) + end + end + end + + def exec_delete(sql, name = "SQL", binds = []) + exec_query(sql, name, binds) + @connection.changes + end + alias :exec_update :exec_delete + + def begin_db_transaction #:nodoc: + log("begin transaction", nil) { @connection.transaction } + end + + def commit_db_transaction #:nodoc: + log("commit transaction", nil) { @connection.commit } + end + + def exec_rollback_db_transaction #:nodoc: + log("rollback transaction", nil) { @connection.rollback } + end + + + private + def execute_batch(sql, name = nil) + if preventing_writes? && write_query?(sql) + raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" + end + + materialize_transactions + + log(sql, name) do + ActiveSupport::Dependencies.interlock.permit_concurrent_loads do + @connection.execute_batch2(sql) + end + end + end + + def last_inserted_id(result) + @connection.last_insert_row_id + end + + def build_fixture_statements(fixture_set) + fixture_set.flat_map do |table_name, fixtures| + next if fixtures.empty? + fixtures.map { |fixture| build_fixture_sql([fixture], table_name) } + end.compact + end + + def build_truncate_statements(*table_names) + truncate_tables = table_names.map do |table_name| + "DELETE FROM #{quote_table_name(table_name)}" + end + combine_multi_statements(truncate_tables) + end + end + end + end +end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb index 70de96326c..cb9d32a577 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb @@ -12,11 +12,16 @@ module ActiveRecord quote_column_name(attr) end + def quote_table_name(name) + @quoted_table_names[name] ||= super.gsub(".", "\".\"").freeze + end + def quote_column_name(name) - @quoted_column_names[name] ||= %Q("#{super.gsub('"', '""')}").freeze + @quoted_column_names[name] ||= %Q("#{super.gsub('"', '""')}") end def quoted_time(value) + value = value.change(year: 2000, month: 1, day: 1) quoted_date(value).sub(/\A\d\d\d\d-\d\d-\d\d /, "2000-01-01 ") end @@ -25,19 +30,19 @@ module ActiveRecord end def quoted_true - ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer ? "1".freeze : "'t'".freeze + "1" end def unquoted_true - ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer ? 1 : "t".freeze + 1 end def quoted_false - ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer ? "0".freeze : "'f'".freeze + "0" end def unquoted_false - ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer ? 0 : "f".freeze + 0 end private diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb index 58e5138e02..e48f59b4f0 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb @@ -7,7 +7,11 @@ module ActiveRecord # Returns an array of indexes for the given table. def indexes(table_name) exec_query("PRAGMA index_list(#{quote_table_name(table_name)})", "SCHEMA").map do |row| - index_sql = query_value(<<-SQL, "SCHEMA") + # Indexes SQLite creates implicitly for internal use start with "sqlite_". + # See https://www.sqlite.org/fileformat2.html#intschema + next if row["name"].starts_with?("sqlite_") + + index_sql = query_value(<<~SQL, "SCHEMA") SELECT sql FROM sqlite_master WHERE name = #{quote(row['name'])} AND type = 'index' @@ -17,19 +21,24 @@ module ActiveRecord WHERE name = #{quote(row['name'])} AND type = 'index' SQL - /\sWHERE\s+(?<where>.+)$/i =~ index_sql + /\bON\b\s*"?(\w+?)"?\s*\((?<expressions>.+?)\)(?:\s*WHERE\b\s*(?<where>.+))?\z/i =~ index_sql columns = exec_query("PRAGMA index_info(#{quote(row['name'])})", "SCHEMA").map do |col| col["name"] end - # Add info on sort order for columns (only desc order is explicitly specified, asc is - # the default) orders = {} - if index_sql # index_sql can be null in case of primary key indexes - index_sql.scan(/"(\w+)" DESC/).flatten.each { |order_column| - orders[order_column] = :desc - } + + if columns.any?(&:nil?) # index created with an expression + columns = expressions + else + # Add info on sort order for columns (only desc order is explicitly specified, + # asc is the default) + if index_sql # index_sql can be null in case of primary key indexes + index_sql.scan(/"(\w+)" DESC/).flatten.each { |order_column| + orders[order_column] = :desc + } + end end IndexDefinition.new( @@ -40,9 +49,35 @@ module ActiveRecord where: where, orders: orders ) + end.compact + end + + def add_foreign_key(from_table, to_table, **options) + alter_table(from_table) do |definition| + to_table = strip_table_name_prefix_and_suffix(to_table) + definition.foreign_key(to_table, options) end end + def remove_foreign_key(from_table, to_table = nil, **options) + to_table ||= options[:to_table] + options = options.except(:name, :to_table) + foreign_keys = foreign_keys(from_table) + + fkey = foreign_keys.detect do |fk| + table = to_table || begin + table = options[:column].to_s.delete_suffix("_id") + Base.pluralize_table_names ? table.pluralize : table + end + table = strip_table_name_prefix_and_suffix(table) + fk_to_table = strip_table_name_prefix_and_suffix(fk.to_table) + fk_to_table == table && options.all? { |k, v| fk.options[k].to_s == v.to_s } + end || raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{to_table || options}") + + foreign_keys.delete(fkey) + alter_table(from_table, foreign_keys) + end + def create_schema_dumper(options) SQLite3::SchemaDumper.create(self, options) end @@ -53,7 +88,7 @@ module ActiveRecord end def create_table_definition(*args) - SQLite3::TableDefinition.new(*args) + SQLite3::TableDefinition.new(self, *args) end def new_column_from_field(table_name, field) @@ -70,14 +105,14 @@ module ActiveRecord end type_metadata = fetch_type_metadata(field["type"]) - Column.new(field["name"], default, type_metadata, field["notnull"].to_i == 0, table_name, nil, field["collation"]) + Column.new(field["name"], default, type_metadata, field["notnull"].to_i == 0, collation: field["collation"]) end def data_source_sql(name = nil, type: nil) scope = quoted_scope(name, type: type) scope[:type] ||= "'table','view'" - sql = "SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence'".dup + sql = +"SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence'" sql << " AND name = #{scope[:name]}" if scope[:name] sql << " AND type IN (#{scope[:type]})" sql diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index a958600446..f5f5827d04 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -4,17 +4,20 @@ require "active_record/connection_adapters/abstract_adapter" require "active_record/connection_adapters/statement_pool" require "active_record/connection_adapters/sqlite3/explain_pretty_printer" require "active_record/connection_adapters/sqlite3/quoting" +require "active_record/connection_adapters/sqlite3/database_statements" require "active_record/connection_adapters/sqlite3/schema_creation" require "active_record/connection_adapters/sqlite3/schema_definitions" require "active_record/connection_adapters/sqlite3/schema_dumper" require "active_record/connection_adapters/sqlite3/schema_statements" -gem "sqlite3", "~> 1.3.6" +gem "sqlite3", "~> 1.4" require "sqlite3" module ActiveRecord module ConnectionHandling # :nodoc: def sqlite3_connection(config) + config = config.symbolize_keys + # Require database. unless config[:database] raise ArgumentError, "No database file specified. Missing argument: database" @@ -31,11 +34,9 @@ module ActiveRecord db = SQLite3::Database.new( config[:database].to_s, - results_as_hash: true + config.merge(results_as_hash: true) ) - db.busy_timeout(ConnectionAdapters::SQLite3Adapter.type_cast_config_to_integer(config[:timeout])) if config[:timeout] - ConnectionAdapters::SQLite3Adapter.new(db, logger, nil, config) rescue Errno::ENOENT => error if error.message.include?("No such file or directory") @@ -54,10 +55,11 @@ module ActiveRecord # # * <tt>:database</tt> - Path to the database file. class SQLite3Adapter < AbstractAdapter - ADAPTER_NAME = "SQLite".freeze + ADAPTER_NAME = "SQLite" include SQLite3::Quoting include SQLite3::SchemaStatements + include SQLite3::DatabaseStatements NATIVE_DATABASE_TYPES = { primary_key: "integer PRIMARY KEY AUTOINCREMENT NOT NULL", @@ -74,36 +76,25 @@ module ActiveRecord json: { name: "json" }, } - ## - # :singleton-method: - # Indicates whether boolean values are stored in sqlite3 databases as 1 - # and 0 or 't' and 'f'. Leaving <tt>ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer</tt> - # set to false is deprecated. SQLite databases have used 't' and 'f' to - # serialize boolean values and must have old data converted to 1 and 0 - # (its native boolean serialization) before setting this flag to true. - # Conversion can be accomplished by setting up a rake task which runs - # - # ExampleModel.where("boolean_column = 't'").update_all(boolean_column: 1) - # ExampleModel.where("boolean_column = 'f'").update_all(boolean_column: 0) - # for all models and all boolean columns, after which the flag must be set - # to true by adding the following to your <tt>application.rb</tt> file: - # - # Rails.application.config.active_record.sqlite3.represent_boolean_as_integer = true - class_attribute :represent_boolean_as_integer, default: false + def self.represent_boolean_as_integer=(value) # :nodoc: + if value == false + raise "`.represent_boolean_as_integer=` is now always true, so make sure your application can work with it and remove this settings." + end + + ActiveSupport::Deprecation.warn( + "`.represent_boolean_as_integer=` is now always true, so setting this is deprecated and will be removed in Rails 6.1." + ) + end class StatementPool < ConnectionAdapters::StatementPool # :nodoc: private def dealloc(stmt) - stmt[:stmt].close unless stmt[:stmt].closed? + stmt.close unless stmt.closed? end end def initialize(connection, logger, connection_options, config) super(connection, logger, config) - - @active = true - @statements = StatementPool.new(self.class.type_cast_config_to_integer(config[:statement_limit])) - configure_connection end @@ -116,15 +107,19 @@ module ActiveRecord end def supports_partial_index? - sqlite_version >= "3.8.0" + true + end + + def supports_expression_index? + database_version >= "3.9.0" end def requires_reloading? true end - def supports_foreign_keys_in_create? - sqlite_version >= "3.6.19" + def supports_foreign_keys? + true end def supports_views? @@ -139,27 +134,29 @@ module ActiveRecord true end - def supports_multi_insert? - sqlite_version >= "3.7.11" + def supports_insert_on_conflict? + database_version >= "3.24.0" end + alias supports_insert_on_duplicate_skip? supports_insert_on_conflict? + alias supports_insert_on_duplicate_update? supports_insert_on_conflict? + alias supports_insert_conflict_target? supports_insert_on_conflict? def active? - @active + !@connection.closed? + end + + def reconnect! + super + connect if @connection.closed? end # Disconnects from the database if already connected. Otherwise, this # method does nothing. def disconnect! super - @active = false @connection.close rescue nil end - # Clears the prepared statements cache. - def clear_cache! - @statements.clear - end - def supports_index_sort_order? true end @@ -184,91 +181,34 @@ module ActiveRecord true end + def supports_lazy_transactions? + true + end + # REFERENTIAL INTEGRITY ==================================== def disable_referential_integrity # :nodoc: - old = query_value("PRAGMA foreign_keys") + old_foreign_keys = query_value("PRAGMA foreign_keys") + old_defer_foreign_keys = query_value("PRAGMA defer_foreign_keys") begin + execute("PRAGMA defer_foreign_keys = ON") execute("PRAGMA foreign_keys = OFF") yield ensure - execute("PRAGMA foreign_keys = #{old}") + execute("PRAGMA defer_foreign_keys = #{old_defer_foreign_keys}") + execute("PRAGMA foreign_keys = #{old_foreign_keys}") end end #-- # DATABASE STATEMENTS ====================================== #++ - def explain(arel, binds = []) sql = "EXPLAIN QUERY PLAN #{to_sql(arel, binds)}" SQLite3::ExplainPrettyPrinter.new.pp(exec_query(sql, "EXPLAIN", [])) end - def exec_query(sql, name = nil, binds = [], prepare: false) - type_casted_binds = type_casted_binds(binds) - - log(sql, name, binds, type_casted_binds) do - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - # Don't cache statements if they are not prepared - unless prepare - stmt = @connection.prepare(sql) - begin - cols = stmt.columns - unless without_prepared_statement?(binds) - stmt.bind_params(type_casted_binds) - end - records = stmt.to_a - ensure - stmt.close - end - else - cache = @statements[sql] ||= { - stmt: @connection.prepare(sql) - } - stmt = cache[:stmt] - cols = cache[:cols] ||= stmt.columns - stmt.reset! - stmt.bind_params(type_casted_binds) - records = stmt.to_a - end - - ActiveRecord::Result.new(cols, records) - end - end - end - - def exec_delete(sql, name = "SQL", binds = []) - exec_query(sql, name, binds) - @connection.changes - end - alias :exec_update :exec_delete - - def last_inserted_id(result) - @connection.last_insert_row_id - end - - def execute(sql, name = nil) #:nodoc: - log(sql, name) do - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - @connection.execute(sql) - end - end - end - - def begin_db_transaction #:nodoc: - log("begin transaction", nil) { @connection.transaction } - end - - def commit_db_transaction #:nodoc: - log("commit transaction", nil) { @connection.commit } - end - - def exec_rollback_db_transaction #:nodoc: - log("rollback transaction", nil) { @connection.rollback } - end - # SCHEMA STATEMENTS ======================================== def primary_keys(table_name) # :nodoc: @@ -290,11 +230,6 @@ module ActiveRecord rename_table_indexes(table_name, new_name) end - def valid_alter_table_type?(type, options = {}) - !invalid_alter_table_type?(type, options) - end - deprecate :valid_alter_table_type? - def add_column(table_name, column_name, type, options = {}) #:nodoc: if invalid_alter_table_type?(type, options) alter_table(table_name) do |definition| @@ -308,6 +243,9 @@ module ActiveRecord def remove_column(table_name, column_name, type = nil, options = {}) #:nodoc: alter_table(table_name) do |definition| definition.remove_column column_name + definition.foreign_keys.delete_if do |_, fk_options| + fk_options[:column] == column_name.to_s + end end end @@ -366,27 +304,36 @@ module ActiveRecord end end - def insert_fixtures(rows, table_name) - ActiveSupport::Deprecation.warn(<<-MSG.squish) - `insert_fixtures` is deprecated and will be removed in the next version of Rails. - Consider using `insert_fixtures_set` for performance improvement. - MSG - insert_fixtures_set(table_name => rows) + def build_insert_sql(insert) # :nodoc: + sql = +"INSERT #{insert.into} #{insert.values_list}" + + if insert.skip_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO NOTHING" + elsif insert.update_duplicates? + sql << " ON CONFLICT #{insert.conflict_target} DO UPDATE SET " + sql << insert.updatable_columns.map { |column| "#{column}=excluded.#{column}" }.join(",") + end + + sql end - def insert_fixtures_set(fixture_set, tables_to_delete = []) - disable_referential_integrity do - transaction(requires_new: true) do - tables_to_delete.each { |table| delete "DELETE FROM #{quote_table_name(table)}", "Fixture Delete" } + def get_database_version # :nodoc: + SQLite3Adapter::Version.new(query_value("SELECT sqlite_version(*)")) + end - fixture_set.each do |table_name, rows| - rows.each { |row| insert_fixture(row, table_name) } - end - end + def check_version # :nodoc: + if database_version < "3.8.0" + raise "Your version of SQLite (#{database_version}) is too old. Active Record supports SQLite >= 3.8." end end private + # See https://www.sqlite.org/limits.html, + # the default value is 999 when not configured. + def bind_params_length + 999 + end + def initialize_type_map(m = type_map) super register_class_with_limit m, %r(int)i, SQLite3Integer @@ -405,14 +352,27 @@ module ActiveRecord type.to_sym == :primary_key || options[:primary_key] end - def alter_table(table_name, options = {}) + def alter_table(table_name, foreign_keys = foreign_keys(table_name), **options) altered_table_name = "a#{table_name}" - caller = lambda { |definition| yield definition if block_given? } + + caller = lambda do |definition| + rename = options[:rename] || {} + foreign_keys.each do |fk| + if column = rename[fk.options[:column]] + fk.options[:column] = column + end + to_table = strip_table_name_prefix_and_suffix(fk.to_table) + definition.foreign_key(to_table, fk.options) + end + + yield definition if block_given? + end transaction do - move_table(table_name, altered_table_name, - options.merge(temporary: true)) - move_table(altered_table_name, table_name, &caller) + disable_referential_integrity do + move_table(table_name, altered_table_name, options.merge(temporary: true)) + move_table(altered_table_name, table_name, &caller) + end end end @@ -442,6 +402,7 @@ module ActiveRecord primary_key: column_name == from_primary_key ) end + yield @definition if block_given? end copy_table_indexes(from, to, options[:rename] || {}) @@ -453,18 +414,18 @@ 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}" name = name[1..-1] end - to_column_names = columns(to).map(&:name) - columns = index.columns.map { |c| rename[c] || c }.select do |column| - to_column_names.include?(column) + columns = index.columns + if columns.is_a?(Array) + to_column_names = columns(to).map(&:name) + columns = columns.map { |c| rename[c] || c }.select do |column| + to_column_names.include?(column) + end end unless columns.empty? @@ -490,22 +451,18 @@ module ActiveRecord SELECT #{quoted_from_columns} FROM #{quote_table_name(from)}") end - def sqlite_version - @sqlite_version ||= SQLite3Adapter::Version.new(query_value("SELECT sqlite_version(*)")) - end - - def translate_exception(exception, message) + def translate_exception(exception, message:, sql:, binds:) case exception.message # SQLite 3.8.2 returns a newly formatted error message: # UNIQUE constraint failed: *table_name*.*column_name* # Older versions of SQLite return: # column *column_name* is not unique when /column(s)? .* (is|are) not unique/, /UNIQUE constraint failed: .*/ - RecordNotUnique.new(message) + RecordNotUnique.new(message, sql: sql, binds: binds) when /.* may not be NULL/, /NOT NULL constraint failed: .*/ - NotNullViolation.new(message) + NotNullViolation.new(message, sql: sql, binds: binds) when /FOREIGN KEY constraint failed/i - InvalidForeignKey.new(message) + InvalidForeignKey.new(message, sql: sql, binds: binds) else super end @@ -515,7 +472,7 @@ module ActiveRecord def table_structure_with_collation(table_name, basic_structure) collation_hash = {} - sql = <<-SQL + sql = <<~SQL SELECT sql FROM (SELECT * FROM sqlite_master UNION ALL SELECT * FROM sqlite_temp_master) @@ -548,7 +505,7 @@ module ActiveRecord column end else - basic_structure.to_hash + basic_structure.to_a end end @@ -556,7 +513,21 @@ module ActiveRecord Arel::Visitors::SQLite.new(self) end + def build_statement_pool + StatementPool.new(self.class.type_cast_config_to_integer(@config[:statement_limit])) + end + + def connect + @connection = ::SQLite3::Database.new( + @config[:database].to_s, + @config.merge(results_as_hash: true) + ) + configure_connection + end + def configure_connection + @connection.busy_timeout(self.class.type_cast_config_to_integer(@config[:timeout])) if @config[:timeout] + execute("PRAGMA foreign_keys = ON", "SCHEMA") end diff --git a/activerecord/lib/active_record/connection_handling.rb b/activerecord/lib/active_record/connection_handling.rb index ee0e651912..53069cd899 100644 --- a/activerecord/lib/active_record/connection_handling.rb +++ b/activerecord/lib/active_record/connection_handling.rb @@ -46,45 +46,143 @@ module ActiveRecord # # The exceptions AdapterNotSpecified, AdapterNotFound and +ArgumentError+ # may be returned on an error. - def establish_connection(config = nil) - raise "Anonymous class is not allowed." unless name + def establish_connection(config_or_env = nil) + config_hash = resolve_config_for_connection(config_or_env) + connection_handler.establish_connection(config_hash) + end - config ||= DEFAULT_ENV.call.to_sym - spec_name = self == Base ? "primary" : name - self.connection_specification_name = spec_name + # Connects a model to the databases specified. The +database+ keyword + # takes a hash consisting of a +role+ and a +database_key+. + # + # This will create a connection handler for switching between connections, + # look up the config hash using the +database_key+ and finally + # establishes a connection to that config. + # + # class AnimalsModel < ApplicationRecord + # self.abstract_class = true + # + # connects_to database: { writing: :primary, reading: :primary_replica } + # end + # + # Returns an array of established connections. + def connects_to(database: {}) + connections = [] - resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(Base.configurations) - spec = resolver.resolve(config).symbolize_keys - spec[:name] = spec_name + database.each do |role, database_key| + config_hash = resolve_config_for_connection(database_key) + handler = lookup_connection_handler(role.to_sym) - # use the primary config if a config is not passed in and - # it's a three tier config - spec = spec[spec_name.to_sym] if spec[spec_name.to_sym] + connections << handler.establish_connection(config_hash) + end - connection_handler.establish_connection(spec) + connections end - class MergeAndResolveDefaultUrlConfig # :nodoc: - def initialize(raw_configurations) - @raw_config = raw_configurations.dup - @env = DEFAULT_ENV.call.to_s - end + # Connects to a database or role (ex writing, reading, or another + # custom role) for the duration of the block. + # + # If a role is passed, Active Record will look up the connection + # based on the requested role: + # + # ActiveRecord::Base.connected_to(role: :writing) do + # Dog.create! # creates dog using dog connection + # end + # + # ActiveRecord::Base.connected_to(role: :reading) do + # Dog.create! # throws exception because we're on a replica + # end + # + # ActiveRecord::Base.connected_to(role: :unknown_ode) do + # # raises exception due to non-existent role + # end + # + # For cases where you may want to connect to a database outside of the model, + # you can use +connected_to+ with a +database+ argument. The +database+ argument + # expects a symbol that corresponds to the database key in your config. + # + # This will connect to a new database for the queries inside the block. + # + # ActiveRecord::Base.connected_to(database: :animals_slow_replica) do + # Dog.run_a_long_query # runs a long query while connected to the +animals_slow_replica+ + # end + def connected_to(database: nil, role: nil, &blk) + if database && role + raise ArgumentError, "connected_to can only accept a `database` or a `role` argument, but not both arguments." + elsif database + if database.is_a?(Hash) + role, database = database.first + role = role.to_sym + else + role = database.to_sym + end + + config_hash = resolve_config_for_connection(database) + handler = lookup_connection_handler(role) - # Returns fully resolved connection hashes. - # Merges connection information from `ENV['DATABASE_URL']` if available. - def resolve - ConnectionAdapters::ConnectionSpecification::Resolver.new(config).resolve_all + with_handler(role) do + handler.establish_connection(config_hash) + yield + end + elsif role + with_handler(role.to_sym, &blk) + else + raise ArgumentError, "must provide a `database` or a `role`." end + end + + # Returns true if role is the current connected role. + # + # ActiveRecord::Base.connected_to(role: :writing) do + # ActiveRecord::Base.connected_to?(role: :writing) #=> true + # ActiveRecord::Base.connected_to?(role: :reading) #=> false + # end + def connected_to?(role:) + current_role == role.to_sym + end + + # Returns the symbol representing the current connected role. + # + # ActiveRecord::Base.connected_to(role: :writing) do + # ActiveRecord::Base.current_role #=> :writing + # end + # + # ActiveRecord::Base.connected_to(role: :reading) do + # ActiveRecord::Base.current_role #=> :reading + # end + def current_role + connection_handlers.key(connection_handler) + end - private - def config - @raw_config.dup.tap do |cfg| - if url = ENV["DATABASE_URL"] - cfg[@env] ||= {} - cfg[@env]["url"] ||= url - end - end + def lookup_connection_handler(handler_key) # :nodoc: + connection_handlers[handler_key] ||= ActiveRecord::ConnectionAdapters::ConnectionHandler.new + end + + def with_handler(handler_key, &blk) # :nodoc: + handler = lookup_connection_handler(handler_key) + swap_connection_handler(handler, &blk) + end + + def resolve_config_for_connection(config_or_env) # :nodoc: + raise "Anonymous class is not allowed." unless name + + config_or_env ||= DEFAULT_ENV.call.to_sym + pool_name = self == Base ? "primary" : name + self.connection_specification_name = pool_name + + resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(Base.configurations) + config_hash = resolver.resolve(config_or_env, pool_name).symbolize_keys + config_hash[:name] = pool_name + + config_hash + end + + # Clears the query cache for all connections associated with the current thread. + def clear_query_caches_for_current_thread + ActiveRecord::Base.connection_handlers.each_value do |handler| + handler.connection_pool_list.each do |pool| + pool.connection.clear_query_cache if pool.active_connection? end + end end # Returns the connection currently associated with the class. This can @@ -145,5 +243,14 @@ module ActiveRecord delegate :clear_active_connections!, :clear_reloadable_connections!, :clear_all_connections!, :flush_idle_connections!, to: :connection_handler + + private + + def swap_connection_handler(handler, &blk) # :nodoc: + old_handler, ActiveRecord::Base.connection_handler = ActiveRecord::Base.connection_handler, handler + yield + ensure + ActiveRecord::Base.connection_handler = old_handler + end end end diff --git a/activerecord/lib/active_record/core.rb b/activerecord/lib/active_record/core.rb index e1a0b2ecf8..eb4b48bc37 100644 --- a/activerecord/lib/active_record/core.rb +++ b/activerecord/lib/active_record/core.rb @@ -2,6 +2,7 @@ require "active_support/core_ext/hash/indifferent_access" require "active_support/core_ext/string/filters" +require "active_support/parameter_filter" require "concurrent/map" module ActiveRecord @@ -26,7 +27,7 @@ module ActiveRecord ## # Contains the database configuration - as is typically stored in config/database.yml - - # as a Hash. + # as an ActiveRecord::DatabaseConfigurations object. # # For example, the following database.yml... # @@ -40,22 +41,18 @@ module ActiveRecord # # ...would result in ActiveRecord::Base.configurations to look like this: # - # { - # 'development' => { - # 'adapter' => 'sqlite3', - # 'database' => 'db/development.sqlite3' - # }, - # 'production' => { - # 'adapter' => 'sqlite3', - # 'database' => 'db/production.sqlite3' - # } - # } + # #<ActiveRecord::DatabaseConfigurations:0x00007fd1acbdf800 @configurations=[ + # #<ActiveRecord::DatabaseConfigurations::HashConfig:0x00007fd1acbded10 @env_name="development", + # @spec_name="primary", @config={"adapter"=>"sqlite3", "database"=>"db/development.sqlite3"}>, + # #<ActiveRecord::DatabaseConfigurations::HashConfig:0x00007fd1acbdea90 @env_name="production", + # @spec_name="primary", @config={"adapter"=>"mysql2", "database"=>"db/production.sqlite3"}> + # ]> def self.configurations=(config) - @@configurations = ActiveRecord::ConnectionHandling::MergeAndResolveDefaultUrlConfig.new(config).resolve + @@configurations = ActiveRecord::DatabaseConfigurations.new(config) end self.configurations = {} - # Returns fully resolved configurations hash + # Returns fully resolved ActiveRecord::DatabaseConfigurations object def self.configurations @@configurations end @@ -99,11 +96,12 @@ module ActiveRecord ## # :singleton-method: # Specify whether schema dump should happen at the end of the - # db:migrate rake task. This is true by default, which is useful for the + # db:migrate rails command. This is true by default, which is useful for the # development environment. This should ideally be false in the production # environment where dumping schema is rarely needed. mattr_accessor :dump_schema_after_migration, instance_writer: false, default: true + mattr_accessor :database_selector, instance_writer: false ## # :singleton-method: # Specifies which database schemas to dump when calling db:structure:dump. @@ -125,25 +123,28 @@ module ActiveRecord mattr_accessor :belongs_to_required_by_default, instance_accessor: false + mattr_accessor :connection_handlers, instance_accessor: false, default: {} + + mattr_accessor :writing_role, instance_accessor: false, default: :writing + + mattr_accessor :reading_role, instance_accessor: false, default: :reading + class_attribute :default_connection_handler, instance_writer: false + self.filter_attributes = [] + def self.connection_handler - ActiveRecord::RuntimeRegistry.connection_handler || default_connection_handler + Thread.current.thread_variable_get("ar_connection_handler") || default_connection_handler end def self.connection_handler=(handler) - ActiveRecord::RuntimeRegistry.connection_handler = handler + Thread.current.thread_variable_set("ar_connection_handler", handler) end self.default_connection_handler = ConnectionAdapters::ConnectionHandler.new end - module ClassMethods # :nodoc: - def allocate - define_attribute_methods - super - end - + module ClassMethods def initialize_find_by_cache # :nodoc: @find_by_statement_cache = { true => Concurrent::Map.new, false => Concurrent::Map.new } end @@ -160,7 +161,7 @@ module ActiveRecord return super if block_given? || primary_key.nil? || scope_attributes? || - columns_hash.include?(inheritance_column) + columns_hash.key?(inheritance_column) && !base_class? id = ids.first @@ -172,19 +173,17 @@ module ActiveRecord where(key => params.bind).limit(1) } - record = statement.execute([id], connection).first + record = statement.execute([id], connection)&.first unless record raise RecordNotFound.new("Couldn't find #{name} with '#{primary_key}'=#{id}", name, primary_key, id) end record - rescue ::RangeError - raise RecordNotFound.new("Couldn't find #{name} with an out of range value for '#{primary_key}'", - name, primary_key) end def find_by(*args) # :nodoc: - return super if scope_attributes? || reflect_on_all_aggregations.any? + return super if scope_attributes? || reflect_on_all_aggregations.any? || + columns_hash.key?(inheritance_column) && !base_class? hash = args.first @@ -204,11 +203,9 @@ module ActiveRecord where(wheres).limit(1) } begin - statement.execute(hash.values, connection).first + statement.execute(hash.values, connection)&.first rescue TypeError raise ActiveRecord::StatementInvalid - rescue ::RangeError - nil end end @@ -220,7 +217,7 @@ module ActiveRecord generated_association_methods end - def generated_association_methods + def generated_association_methods # :nodoc: @generated_association_methods ||= begin mod = const_set(:GeneratedAssociationMethods, Module.new) private_constant :GeneratedAssociationMethods @@ -230,8 +227,20 @@ module ActiveRecord end end + # Returns columns which shouldn't be exposed while calling +#inspect+. + def filter_attributes + if defined?(@filter_attributes) + @filter_attributes + else + superclass.filter_attributes + end + end + + # Specifies columns which shouldn't be exposed while calling +#inspect+. + attr_writer :filter_attributes + # Returns a string like 'Post(id:integer, title:string, body:text)' - def inspect + def inspect # :nodoc: if self == Base super elsif abstract_class? @@ -247,7 +256,7 @@ module ActiveRecord end # Overwrite the default class equality method to provide support for decorated models. - def ===(object) + def ===(object) # :nodoc: object.is_a?(self) end @@ -273,6 +282,10 @@ module ActiveRecord TypeCaster::Map.new(self) end + def _internal? # :nodoc: + false + end + private def cached_find_by_statement(key, &block) @@ -331,13 +344,21 @@ module ActiveRecord # post = Post.allocate # post.init_with(coder) # post.title # => 'hello world' - def init_with(coder) + def init_with(coder, &block) coder = LegacyYamlAdapter.convert(self.class, coder) - @attributes = self.class.yaml_encoder.decode(coder) + attributes = self.class.yaml_encoder.decode(coder) + init_with_attributes(attributes, coder["new_record"], &block) + end + ## + # Initialize an empty model object from +attributes+. + # +attributes+ should be an attributes object, and unlike the + # `initialize` method, no assignment calls are made per attribute. + def init_with_attributes(attributes, new_record = false) # :nodoc: init_internals - @new_record = coder["new_record"] + @new_record = new_record + @attributes = attributes self.class.define_attribute_methods @@ -457,6 +478,14 @@ module ActiveRecord end end + def present? # :nodoc: + true + end + + def blank? # :nodoc: + false + end + # Returns +true+ if the record is read only. Records loaded through joins with piggy-back # attributes will be marked as read only since they cannot be saved. def readonly? @@ -479,7 +508,14 @@ module ActiveRecord inspection = if defined?(@attributes) && @attributes self.class.attribute_names.collect do |name| if has_attribute?(name) - "#{name}: #{attribute_for_inspect(name)}" + attr = _read_attribute(name) + value = if attr.nil? + attr.inspect + else + attr = format_for_inspect(attr) + inspection_filter.filter_param(name, attr) + end + "#{name}: #{value}" end end.compact.join(", ") else @@ -495,15 +531,16 @@ module ActiveRecord return super if custom_inspect_method_defined? pp.object_address_group(self) do if defined?(@attributes) && @attributes - column_names = self.class.column_names.select { |name| has_attribute?(name) || new_record? } - pp.seplist(column_names, proc { pp.text "," }) do |column_name| - column_value = read_attribute(column_name) + attr_names = self.class.attribute_names.select { |name| has_attribute?(name) } + pp.seplist(attr_names, proc { pp.text "," }) do |attr_name| pp.breakable " " pp.group(1) do - pp.text column_name + pp.text attr_name pp.text ":" pp.breakable - pp.pp column_value + value = _read_attribute(attr_name) + value = inspection_filter.filter_param(attr_name, value) unless value.nil? + pp.pp value end end else @@ -554,5 +591,15 @@ module ActiveRecord def custom_inspect_method_defined? self.class.instance_method(:inspect).owner != ActiveRecord::Base.instance_method(:inspect).owner end + + def inspection_filter + @inspection_filter ||= begin + mask = DelegateClass(::String).new(ActiveSupport::ParameterFilter::FILTERED) + def mask.pretty_print(pp) + pp.text __getobj__ + end + ActiveSupport::ParameterFilter.new(self.class.filter_attributes, mask: mask) + end + end end end diff --git a/activerecord/lib/active_record/counter_cache.rb b/activerecord/lib/active_record/counter_cache.rb index ee4f818cbf..27c1b7a311 100644 --- a/activerecord/lib/active_record/counter_cache.rb +++ b/activerecord/lib/active_record/counter_cache.rb @@ -47,8 +47,12 @@ module ActiveRecord reflection = child_class._reflections.values.find { |e| e.belongs_to? && e.foreign_key.to_s == foreign_key && e.options[:counter_cache].present? } counter_name = reflection.counter_cache_column - updates = { counter_name.to_sym => object.send(counter_association).count(:all) } - updates.merge!(touch_updates(touch)) if touch + updates = { counter_name => object.send(counter_association).count(:all) } + + if touch + names = touch if touch != true + updates.merge!(touch_attributes_with_time(*names)) + end unscoped.where(primary_key => object.id).update_all(updates) end @@ -68,8 +72,8 @@ module ActiveRecord # * +counters+ - A Hash containing the names of the fields # to update as keys and the amount to update the field by as values. # * <tt>:touch</tt> option - Touch timestamp columns when updating. - # Pass +true+ to touch +updated_at+ and/or +updated_on+. Pass a symbol to - # touch that column or an array of symbols to touch just those ones. + # If attribute names are passed, they are updated along with updated_at/on + # attributes. # # ==== Examples # @@ -98,20 +102,7 @@ module ActiveRecord # # `updated_at` = '2016-10-13T09:59:23-05:00' # # WHERE id IN (10, 15) def update_counters(id, counters) - touch = counters.delete(:touch) - - updates = counters.map do |counter_name, value| - operator = value < 0 ? "-" : "+" - quoted_column = connection.quote_column_name(counter_name) - "#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{value.abs}" - end - - if touch - touch_updates = touch_updates(touch) - updates << sanitize_sql_for_assignment(touch_updates) unless touch_updates.empty? - end - - unscoped.where(primary_key => id).update_all updates.join(", ") + unscoped.where!(primary_key => id).update_counters(counters) end # Increment a numeric field by one, via a direct SQL update. @@ -165,24 +156,14 @@ module ActiveRecord def decrement_counter(counter_name, id, touch: nil) update_counters(id, counter_name => -1, touch: touch) end - - private - def touch_updates(touch) - touch = timestamp_attributes_for_update_in_model if touch == true - touch_time = current_time_from_proper_timezone - Array(touch).map { |column| [ column, touch_time ] }.to_h - end end private - - def _create_record(*) + def _create_record(attribute_names = self.attribute_names) id = super each_counter_cached_associations do |association| - if send(association.reflection.name) - association.increment_counters - end + association.increment_counters end id @@ -195,9 +176,7 @@ module ActiveRecord each_counter_cached_associations do |association| foreign_key = association.reflection.foreign_key.to_sym unless destroyed_by_association && destroyed_by_association.foreign_key.to_sym == foreign_key - if send(association.reflection.name) - association.decrement_counters - end + association.decrement_counters end end end diff --git a/activerecord/lib/active_record/database_configurations.rb b/activerecord/lib/active_record/database_configurations.rb index 09aef62753..44b5cfc738 100644 --- a/activerecord/lib/active_record/database_configurations.rb +++ b/activerecord/lib/active_record/database_configurations.rb @@ -1,63 +1,204 @@ # frozen_string_literal: true +require "active_record/database_configurations/database_config" +require "active_record/database_configurations/hash_config" +require "active_record/database_configurations/url_config" + module ActiveRecord - module DatabaseConfigurations # :nodoc: - class DatabaseConfig - attr_reader :env_name, :spec_name, :config + # ActiveRecord::DatabaseConfigurations returns an array of DatabaseConfig + # objects (either a HashConfig or UrlConfig) that are constructed from the + # application's database configuration hash or URL string. + class DatabaseConfigurations + attr_reader :configurations + delegate :any?, to: :configurations + + def initialize(configurations = {}) + @configurations = build_configs(configurations) + end + + # Collects the configs for the environment and optionally the specification + # name passed in. To include replica configurations pass <tt>include_replicas: true</tt>. + # + # If a spec name is provided a single DatabaseConfig object will be + # returned, otherwise an array of DatabaseConfig objects will be + # returned that corresponds with the environment and type requested. + # + # ==== Options + # + # * <tt>env_name:</tt> The environment name. Defaults to +nil+ which will collect + # configs for all environments. + # * <tt>spec_name:</tt> The specification name (i.e. primary, animals, etc.). Defaults + # to +nil+. + # * <tt>include_replicas:</tt> Determines whether to include replicas in + # the returned list. Most of the time we're only iterating over the write + # connection (i.e. migrations don't need to run for the write and read connection). + # Defaults to +false+. + def configs_for(env_name: nil, spec_name: nil, include_replicas: false) + configs = env_with_configs(env_name) - def initialize(env_name, spec_name, config) - @env_name = env_name - @spec_name = spec_name - @config = config + unless include_replicas + configs = configs.select do |db_config| + !db_config.replica? + end + end + + if spec_name + configs.find do |db_config| + db_config.spec_name == spec_name + end + else + configs end end - # Selects the config for the specified environment and specification name + # Returns the config hash that corresponds with the environment + # + # If the application has multiple databases +default_hash+ will + # return the first config hash for the environment. # - # For example if passed :development, and :animals it will select the database - # under the :development and :animals configuration level - def self.config_for_env_and_spec(environment, specification_name, configs = ActiveRecord::Base.configurations) # :nodoc: - configs_for(environment, configs).find do |db_config| - db_config.spec_name == specification_name + # { database: "my_db", adapter: "mysql2" } + def default_hash(env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call.to_s) + default = find_db_config(env) + default.config if default + end + alias :[] :default_hash + + # Returns a single DatabaseConfig object based on the requested environment. + # + # If the application has multiple databases +find_db_config+ will return + # the first DatabaseConfig for the environment. + def find_db_config(env) + configurations.find do |db_config| + db_config.env_name == env.to_s || + (db_config.for_current_env? && db_config.spec_name == env.to_s) + end + end + + # Returns the DatabaseConfigurations object as a Hash. + def to_h + configs = configurations.reverse.inject({}) do |memo, db_config| + memo.merge(db_config.to_legacy_hash) end + + Hash[configs.to_a.reverse] end - # Collects the configs for the environment passed in. + # Checks if the application's configurations are empty. # - # If a block is given returns the specification name and configuration - # otherwise returns an array of DatabaseConfig structs for the environment. - def self.configs_for(env, configs = ActiveRecord::Base.configurations, &blk) # :nodoc: - env_with_configs = db_configs(configs).select do |db_config| - db_config.env_name == env + # Aliased to blank? + def empty? + configurations.empty? + end + alias :blank? :empty? + + private + def env_with_configs(env = nil) + if env + configurations.select { |db_config| db_config.env_name == env } + else + configurations + end end - if block_given? - env_with_configs.each do |env_with_config| - yield env_with_config.spec_name, env_with_config.config + def build_configs(configs) + return configs.configurations if configs.is_a?(DatabaseConfigurations) + return configs if configs.is_a?(Array) + + build_db_config = configs.each_pair.flat_map do |env_name, config| + walk_configs(env_name.to_s, "primary", config) + end.flatten.compact + + if url = ENV["DATABASE_URL"] + build_url_config(url, build_db_config) + else + build_db_config end - else - env_with_configs end - end - # Given an env, spec and config creates DatabaseConfig structs with - # each attribute set. - def self.walk_configs(env_name, spec_name, config) # :nodoc: - if config["database"] || config["url"] || env_name == "default" - DatabaseConfig.new(env_name, spec_name, config) - else - config.each_pair.map do |sub_spec_name, sub_config| - walk_configs(env_name, sub_spec_name, sub_config) + def walk_configs(env_name, spec_name, config) + case config + when String + build_db_config_from_string(env_name, spec_name, config) + when Hash + build_db_config_from_hash(env_name, spec_name, config.stringify_keys) end end - end - # Walks all the configs passed in and returns an array - # of DatabaseConfig structs for each configuration. - def self.db_configs(configs = ActiveRecord::Base.configurations) # :nodoc: - configs.each_pair.flat_map do |env_name, config| - walk_configs(env_name, "primary", config) + def build_db_config_from_string(env_name, spec_name, config) + url = config + uri = URI.parse(url) + if uri.try(:scheme) + ActiveRecord::DatabaseConfigurations::UrlConfig.new(env_name, spec_name, url) + end + rescue URI::InvalidURIError + ActiveRecord::DatabaseConfigurations::HashConfig.new(env_name, spec_name, config) + end + + def build_db_config_from_hash(env_name, spec_name, config) + if config.has_key?("url") + url = config["url"] + config_without_url = config.dup + config_without_url.delete "url" + + ActiveRecord::DatabaseConfigurations::UrlConfig.new(env_name, spec_name, url, config_without_url) + elsif config["database"] || (config.size == 1 && config.values.all? { |v| v.is_a? String }) + ActiveRecord::DatabaseConfigurations::HashConfig.new(env_name, spec_name, config) + else + config.each_pair.map do |sub_spec_name, sub_config| + walk_configs(env_name, sub_spec_name, sub_config) + end + end + end + + def build_url_config(url, configs) + env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call.to_s + + if original_config = configs.find(&:for_current_env?) + if original_config.url_config? + configs + else + configs.map do |config| + ActiveRecord::DatabaseConfigurations::UrlConfig.new(config.env_name, config.spec_name, url, config.config) + end + end + else + configs + [ActiveRecord::DatabaseConfigurations::UrlConfig.new(env, "primary", url)] + end + end + + def method_missing(method, *args, &blk) + case method + when :each, :first + throw_getter_deprecation(method) + configurations.send(method, *args, &blk) + when :fetch + throw_getter_deprecation(method) + configs_for(env_name: args.first) + when :values + throw_getter_deprecation(method) + configurations.map(&:config) + when :[]= + throw_setter_deprecation(method) + + env_name = args[0] + config = args[1] + + remaining_configs = configurations.reject { |db_config| db_config.env_name == env_name } + new_config = build_configs(env_name => config) + new_configs = remaining_configs + new_config + + ActiveRecord::Base.configurations = new_configs + else + raise NotImplementedError, "`ActiveRecord::Base.configurations` in Rails 6 now returns an object instead of a hash. The `#{method}` method is not supported. Please use `configs_for` or consult the documentation for supported methods." + end + end + + def throw_setter_deprecation(method) + ActiveSupport::Deprecation.warn("Setting `ActiveRecord::Base.configurations` with `#{method}` is deprecated. Use `ActiveRecord::Base.configurations=` directly to set the configurations instead.") + end + + def throw_getter_deprecation(method) + ActiveSupport::Deprecation.warn("`ActiveRecord::Base.configurations` no longer returns a hash. Methods that act on the hash like `#{method}` are deprecated and will be removed in Rails 6.1. Use the `configs_for` method to collect and iterate over the database configurations.") end - end end end diff --git a/activerecord/lib/active_record/database_configurations/database_config.rb b/activerecord/lib/active_record/database_configurations/database_config.rb new file mode 100644 index 0000000000..adc37cc439 --- /dev/null +++ b/activerecord/lib/active_record/database_configurations/database_config.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module ActiveRecord + class DatabaseConfigurations + # ActiveRecord::Base.configurations will return either a HashConfig or + # UrlConfig respectively. It will never return a DatabaseConfig object, + # as this is the parent class for the types of database configuration objects. + class DatabaseConfig # :nodoc: + attr_reader :env_name, :spec_name + + def initialize(env_name, spec_name) + @env_name = env_name + @spec_name = spec_name + end + + def replica? + raise NotImplementedError + end + + def migrations_paths + raise NotImplementedError + end + + def url_config? + false + end + + def to_legacy_hash + { env_name => config } + end + + def for_current_env? + env_name == ActiveRecord::ConnectionHandling::DEFAULT_ENV.call + end + end + end +end diff --git a/activerecord/lib/active_record/database_configurations/hash_config.rb b/activerecord/lib/active_record/database_configurations/hash_config.rb new file mode 100644 index 0000000000..e31ff09391 --- /dev/null +++ b/activerecord/lib/active_record/database_configurations/hash_config.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module ActiveRecord + class DatabaseConfigurations + # A HashConfig object is created for each database configuration entry that + # is created from a hash. + # + # A hash config: + # + # { "development" => { "database" => "db_name" } } + # + # Becomes: + # + # #<ActiveRecord::DatabaseConfigurations::HashConfig:0x00007fd1acbded10 + # @env_name="development", @spec_name="primary", @config={"database"=>"db_name"}> + # + # ==== Options + # + # * <tt>:env_name</tt> - The Rails environment, i.e. "development". + # * <tt>:spec_name</tt> - The specification name. In a standard two-tier + # database configuration this will default to "primary". In a multiple + # database three-tier database configuration this corresponds to the name + # used in the second tier, for example "primary_readonly". + # * <tt>:config</tt> - The config hash. This is the hash that contains the + # database adapter, name, and other important information for database + # connections. + class HashConfig < DatabaseConfig + attr_reader :config + + def initialize(env_name, spec_name, config) + super(env_name, spec_name) + @config = config + end + + # Determines whether a database configuration is for a replica / readonly + # connection. If the +replica+ key is present in the config, +replica?+ will + # return +true+. + def replica? + config["replica"] + end + + # The migrations paths for a database configuration. If the + # +migrations_paths+ key is present in the config, +migrations_paths+ + # will return its value. + def migrations_paths + config["migrations_paths"] + end + end + end +end diff --git a/activerecord/lib/active_record/database_configurations/url_config.rb b/activerecord/lib/active_record/database_configurations/url_config.rb new file mode 100644 index 0000000000..e2d30ae416 --- /dev/null +++ b/activerecord/lib/active_record/database_configurations/url_config.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module ActiveRecord + class DatabaseConfigurations + # A UrlConfig object is created for each database configuration + # entry that is created from a URL. This can either be a URL string + # or a hash with a URL in place of the config hash. + # + # A URL config: + # + # postgres://localhost/foo + # + # Becomes: + # + # #<ActiveRecord::DatabaseConfigurations::UrlConfig:0x00007fdc3238f340 + # @env_name="default_env", @spec_name="primary", + # @config={"adapter"=>"postgresql", "database"=>"foo", "host"=>"localhost"}, + # @url="postgres://localhost/foo"> + # + # ==== Options + # + # * <tt>:env_name</tt> - The Rails environment, ie "development". + # * <tt>:spec_name</tt> - The specification name. In a standard two-tier + # database configuration this will default to "primary". In a multiple + # database three-tier database configuration this corresponds to the name + # used in the second tier, for example "primary_readonly". + # * <tt>:url</tt> - The database URL. + # * <tt>:config</tt> - The config hash. This is the hash that contains the + # database adapter, name, and other important information for database + # connections. + class UrlConfig < DatabaseConfig + attr_reader :url, :config + + def initialize(env_name, spec_name, url, config = {}) + super(env_name, spec_name) + @config = build_config(config, url) + @url = url + end + + def url_config? # :nodoc: + true + end + + # Determines whether a database configuration is for a replica / readonly + # connection. If the +replica+ key is present in the config, +replica?+ will + # return +true+. + def replica? + config["replica"] + end + + # The migrations paths for a database configuration. If the + # +migrations_paths+ key is present in the config, +migrations_paths+ + # will return its value. + def migrations_paths + config["migrations_paths"] + end + + private + + def build_url_hash(url) + if url.nil? || /^jdbc:/.match?(url) + { "url" => url } + else + ActiveRecord::ConnectionAdapters::ConnectionSpecification::ConnectionUrlResolver.new(url).to_hash + end + end + + def build_config(original_config, url) + hash = build_url_hash(url) + + if original_config[env_name] + original_config[env_name].merge(hash) + else + original_config.merge(hash) + end + end + end + end +end diff --git a/activerecord/lib/active_record/enum.rb b/activerecord/lib/active_record/enum.rb index 23ecb24542..8077630aeb 100644 --- a/activerecord/lib/active_record/enum.rb +++ b/activerecord/lib/active_record/enum.rb @@ -31,7 +31,9 @@ module ActiveRecord # as well. With the above example: # # Conversation.active + # Conversation.not_active # Conversation.archived + # Conversation.not_archived # # Of course, you can also query them directly if the scopes don't fit your # needs: @@ -149,14 +151,16 @@ module ActiveRecord klass = self enum_prefix = definitions.delete(:_prefix) enum_suffix = definitions.delete(:_suffix) + enum_scopes = definitions.delete(:_scopes) definitions.each do |name, values| + assert_valid_enum_definition_values(values) # statuses = { } enum_values = ActiveSupport::HashWithIndifferentAccess.new name = name.to_s # def self.statuses() statuses end detect_enum_conflict!(name, name.pluralize, true) - singleton_class.send(:define_method, name.pluralize) { enum_values } + singleton_class.define_method(name.pluralize) { enum_values } defined_enums[name] = enum_values detect_enum_conflict!(name, name) @@ -194,10 +198,17 @@ module ActiveRecord define_method("#{value_method_name}!") { update!(attr => value) } # scope :active, -> { where(status: 0) } - klass.send(:detect_enum_conflict!, name, value_method_name, true) - klass.scope value_method_name, -> { where(attr => value) } + # scope :not_active, -> { where.not(status: 0) } + if enum_scopes != false + klass.send(:detect_enum_conflict!, name, value_method_name, true) + klass.scope value_method_name, -> { where(attr => value) } + + klass.send(:detect_enum_conflict!, name, "not_#{value_method_name}", true) + klass.scope "not_#{value_method_name}", -> { where.not(attr => value) } + end end end + enum_values.freeze end end @@ -210,10 +221,24 @@ module ActiveRecord end end + def assert_valid_enum_definition_values(values) + unless values.is_a?(Hash) || values.all? { |v| v.is_a?(Symbol) } || values.all? { |v| v.is_a?(String) } + error_message = <<~MSG + Enum values #{values} must be either a hash, an array of symbols, or an array of strings. + MSG + raise ArgumentError, error_message + end + + if values.is_a?(Hash) && values.keys.any?(&:blank?) || values.is_a?(Array) && values.any?(&:blank?) + raise ArgumentError, "Enum label name must not be blank." + end + end + ENUM_CONFLICT_MESSAGE = \ "You tried to define an enum named \"%{enum}\" on the model \"%{klass}\", but " \ "this will generate a %{type} method \"%{method}\", which is already defined " \ "by %{source}." + private_constant :ENUM_CONFLICT_MESSAGE def detect_enum_conflict!(enum_name, method_name, klass_method = false) if klass_method && dangerous_class_method?(method_name) diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb index c2a180c939..60cf9818c1 100644 --- a/activerecord/lib/active_record/errors.rb +++ b/activerecord/lib/active_record/errors.rb @@ -49,6 +49,10 @@ module ActiveRecord class ConnectionNotEstablished < ActiveRecordError end + # Raised when a write to the database is attempted on a read only connection. + class ReadOnlyError < ActiveRecordError + end + # Raised when Active Record cannot find a record by given id or set of ids. class RecordNotFound < ActiveRecordError attr_reader :model, :primary_key, :id @@ -64,7 +68,7 @@ module ActiveRecord # Raised by {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!] and # {ActiveRecord::Base.create!}[rdoc-ref:Persistence::ClassMethods#create!] - # methods when a record is invalid and can not be saved. + # methods when a record is invalid and cannot be saved. class RecordNotSaved < ActiveRecordError attr_reader :record @@ -97,9 +101,13 @@ module ActiveRecord # # Wraps the underlying database error as +cause+. class StatementInvalid < ActiveRecordError - def initialize(message = nil) + def initialize(message = nil, sql: nil, binds: nil) super(message || $!.try(:message)) + @sql = sql + @binds = binds end + + attr_reader :sql, :binds end # Defunct wrapper class kept for compatibility. @@ -111,22 +119,33 @@ module ActiveRecord class RecordNotUnique < WrappedDatabaseException end - # Raised when a record cannot be inserted or updated because it references a non-existent record. + # Raised when a record cannot be inserted or updated because it references a non-existent record, + # or when a record cannot be deleted because a parent record references it. class InvalidForeignKey < WrappedDatabaseException end # Raised when a foreign key constraint cannot be added because the column type does not match the referenced column type. class MismatchedForeignKey < StatementInvalid - def initialize(adapter = nil, message: nil, table: nil, foreign_key: nil, target_table: nil, primary_key: nil) - @adapter = adapter + def initialize( + message: nil, + sql: nil, + binds: nil, + table: nil, + foreign_key: nil, + target_table: nil, + primary_key: nil, + primary_key_column: nil + ) if table - msg = +<<~EOM - Column `#{foreign_key}` on table `#{table}` has a type of `#{column_type(table, foreign_key)}`. - This does not match column `#{primary_key}` on `#{target_table}`, which has type `#{column_type(target_table, primary_key)}`. - To resolve this issue, change the type of the `#{foreign_key}` column on `#{table}` to be :integer. (For example `t.integer #{foreign_key}`). + type = primary_key_column.bigint? ? :bigint : primary_key_column.type + msg = <<~EOM.squish + Column `#{foreign_key}` on table `#{table}` does not match column `#{primary_key}` on `#{target_table}`, + which has type `#{primary_key_column.sql_type}`. + To resolve this issue, change the type of the `#{foreign_key}` column on `#{table}` to be :#{type}. + (For example `t.#{type} :#{foreign_key}`). EOM else - msg = +<<~EOM + msg = <<~EOM.squish There is a mismatch between the foreign key and primary key column types. Verify that the foreign key column type and the primary key of the associated table match types. EOM @@ -134,13 +153,8 @@ module ActiveRecord if message msg << "\nOriginal message: #{message}" end - super(msg) + super(msg, sql: sql, binds: binds) end - - private - def column_type(table, column) - @adapter.columns(table).detect { |c| c.name == column }.sql_type - end end # Raised when a record cannot be inserted or updated because it would violate a not null constraint. diff --git a/activerecord/lib/active_record/explain.rb b/activerecord/lib/active_record/explain.rb index 7ccb938888..919e96cd7a 100644 --- a/activerecord/lib/active_record/explain.rb +++ b/activerecord/lib/active_record/explain.rb @@ -18,7 +18,7 @@ module ActiveRecord # Returns a formatted string ready to be logged. def exec_explain(queries) # :nodoc: str = queries.map do |sql, binds| - msg = "EXPLAIN for: #{sql}".dup + msg = +"EXPLAIN for: #{sql}" unless binds.empty? msg << " " msg << binds.map { |attr| render_bind(attr) }.inspect diff --git a/activerecord/lib/active_record/fixture_set/model_metadata.rb b/activerecord/lib/active_record/fixture_set/model_metadata.rb new file mode 100644 index 0000000000..fb23df6f45 --- /dev/null +++ b/activerecord/lib/active_record/fixture_set/model_metadata.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ActiveRecord + class FixtureSet + class ModelMetadata # :nodoc: + def initialize(model_class) + @model_class = model_class + end + + def primary_key_name + @primary_key_name ||= @model_class && @model_class.primary_key + end + + def primary_key_type + @primary_key_type ||= @model_class && @model_class.type_for_attribute(@model_class.primary_key).type + end + + def has_primary_key_column? + @has_primary_key_column ||= primary_key_name && + @model_class.columns.any? { |col| col.name == primary_key_name } + end + + def timestamp_column_names + @timestamp_column_names ||= + %w(created_at created_on updated_at updated_on) & @model_class.column_names + end + + def inheritance_column_name + @inheritance_column_name ||= @model_class && @model_class.inheritance_column + end + end + end +end diff --git a/activerecord/lib/active_record/fixture_set/render_context.rb b/activerecord/lib/active_record/fixture_set/render_context.rb new file mode 100644 index 0000000000..c90b5343dc --- /dev/null +++ b/activerecord/lib/active_record/fixture_set/render_context.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# NOTE: This class has to be defined in compact style in +# order for rendering context subclassing to work correctly. +class ActiveRecord::FixtureSet::RenderContext # :nodoc: + def self.create_subclass + Class.new(ActiveRecord::FixtureSet.context_class) do + def get_binding + binding() + end + + def binary(path) + %(!!binary "#{Base64.strict_encode64(File.read(path))}") + end + end + end +end diff --git a/activerecord/lib/active_record/fixture_set/table_row.rb b/activerecord/lib/active_record/fixture_set/table_row.rb new file mode 100644 index 0000000000..cb4726f1ee --- /dev/null +++ b/activerecord/lib/active_record/fixture_set/table_row.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +module ActiveRecord + class FixtureSet + class TableRow # :nodoc: + class ReflectionProxy # :nodoc: + def initialize(association) + @association = association + end + + def join_table + @association.join_table + end + + def name + @association.name + end + + def primary_key_type + @association.klass.type_for_attribute(@association.klass.primary_key).type + end + end + + class HasManyThroughProxy < ReflectionProxy # :nodoc: + def rhs_key + @association.foreign_key + end + + def lhs_key + @association.through_reflection.foreign_key + end + + def join_table + @association.through_reflection.table_name + end + end + + def initialize(fixture, table_rows:, label:, now:) + @table_rows = table_rows + @label = label + @now = now + @row = fixture.to_hash + fill_row_model_attributes + end + + def to_hash + @row + end + + private + + def model_metadata + @table_rows.model_metadata + end + + def model_class + @table_rows.model_class + end + + def fill_row_model_attributes + return unless model_class + fill_timestamps + interpolate_label + generate_primary_key + resolve_enums + resolve_sti_reflections + end + + def reflection_class + @reflection_class ||= if @row.include?(model_metadata.inheritance_column_name) + @row[model_metadata.inheritance_column_name].constantize rescue model_class + else + model_class + end + end + + def fill_timestamps + # fill in timestamp columns if they aren't specified and the model is set to record_timestamps + if model_class.record_timestamps + model_metadata.timestamp_column_names.each do |c_name| + @row[c_name] = @now unless @row.key?(c_name) + end + end + end + + def interpolate_label + # interpolate the fixture label + @row.each do |key, value| + @row[key] = value.gsub("$LABEL", @label.to_s) if value.is_a?(String) + end + end + + def generate_primary_key + # generate a primary key if necessary + if model_metadata.has_primary_key_column? && !@row.include?(model_metadata.primary_key_name) + @row[model_metadata.primary_key_name] = ActiveRecord::FixtureSet.identify( + @label, model_metadata.primary_key_type + ) + end + end + + def resolve_enums + model_class.defined_enums.each do |name, values| + if @row.include?(name) + @row[name] = values.fetch(@row[name], @row[name]) + end + end + end + + def resolve_sti_reflections + # If STI is used, find the correct subclass for association reflection + reflection_class._reflections.each_value do |association| + case association.macro + when :belongs_to + # Do not replace association name with association foreign key if they are named the same + fk_name = (association.options[:foreign_key] || "#{association.name}_id").to_s + + if association.name.to_s != fk_name && value = @row.delete(association.name.to_s) + if association.polymorphic? && value.sub!(/\s*\(([^\)]*)\)\s*$/, "") + # support polymorphic belongs_to as "label (Type)" + @row[association.foreign_type] = $1 + end + + fk_type = reflection_class.type_for_attribute(fk_name).type + @row[fk_name] = ActiveRecord::FixtureSet.identify(value, fk_type) + end + when :has_many + if association.options[:through] + add_join_records(HasManyThroughProxy.new(association)) + end + end + end + end + + def add_join_records(association) + # This is the case when the join table has no fixtures file + if (targets = @row.delete(association.name.to_s)) + table_name = association.join_table + column_type = association.primary_key_type + lhs_key = association.lhs_key + rhs_key = association.rhs_key + + targets = targets.is_a?(Array) ? targets : targets.split(/\s*,\s*/) + joins = targets.map do |target| + { lhs_key => @row[model_metadata.primary_key_name], + rhs_key => ActiveRecord::FixtureSet.identify(target, column_type) } + end + @table_rows.tables[table_name].concat(joins) + end + end + end + end +end diff --git a/activerecord/lib/active_record/fixture_set/table_rows.rb b/activerecord/lib/active_record/fixture_set/table_rows.rb new file mode 100644 index 0000000000..23814b6cb5 --- /dev/null +++ b/activerecord/lib/active_record/fixture_set/table_rows.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "active_record/fixture_set/table_row" +require "active_record/fixture_set/model_metadata" + +module ActiveRecord + class FixtureSet + class TableRows # :nodoc: + def initialize(table_name, model_class:, fixtures:, config:) + @model_class = model_class + + # track any join tables we need to insert later + @tables = Hash.new { |h, table| h[table] = [] } + + # ensure this table is loaded before any HABTM associations + @tables[table_name] = nil + + build_table_rows_from(table_name, fixtures, config) + end + + attr_reader :tables, :model_class + + def to_hash + @tables.transform_values { |rows| rows.map(&:to_hash) } + end + + def model_metadata + @model_metadata ||= ModelMetadata.new(model_class) + end + + private + + def build_table_rows_from(table_name, fixtures, config) + now = config.default_timezone == :utc ? Time.now.utc : Time.now + + @tables[table_name] = fixtures.map do |label, fixture| + TableRow.new( + fixture, + table_rows: self, + label: label, + now: now, + ) + end + end + end + end +end diff --git a/activerecord/lib/active_record/fixtures.rb b/activerecord/lib/active_record/fixtures.rb index 8f022ff7a7..327121a2a2 100644 --- a/activerecord/lib/active_record/fixtures.rb +++ b/activerecord/lib/active_record/fixtures.rb @@ -7,6 +7,9 @@ require "set" require "active_support/dependencies" require "active_support/core_ext/digest/uuid" require "active_record/fixture_set/file" +require "active_record/fixture_set/render_context" +require "active_record/fixture_set/table_rows" +require "active_record/test_fixtures" require "active_record/errors" module ActiveRecord @@ -179,8 +182,8 @@ module ActiveRecord # end # end # - # If you preload your test database with all fixture data (probably in the rake task) and use - # transactional tests, then you may omit all fixtures declarations in your test cases since + # If you preload your test database with all fixture data (probably by running `rails db:fixtures:load`) + # and use transactional tests, then you may omit all fixtures declarations in your test cases since # all the data's already there and every case rolls back its changes. # # In order to use instantiated fixtures with preloaded data, set +self.pre_loaded_fixtures+ to @@ -440,60 +443,6 @@ module ActiveRecord @@all_cached_fixtures = Hash.new { |h, k| h[k] = {} } - def self.default_fixture_model_name(fixture_set_name, config = ActiveRecord::Base) # :nodoc: - config.pluralize_table_names ? - fixture_set_name.singularize.camelize : - fixture_set_name.camelize - end - - def self.default_fixture_table_name(fixture_set_name, config = ActiveRecord::Base) # :nodoc: - "#{ config.table_name_prefix }"\ - "#{ fixture_set_name.tr('/', '_') }"\ - "#{ config.table_name_suffix }".to_sym - end - - def self.reset_cache - @@all_cached_fixtures.clear - end - - def self.cache_for_connection(connection) - @@all_cached_fixtures[connection] - end - - def self.fixture_is_cached?(connection, table_name) - cache_for_connection(connection)[table_name] - end - - def self.cached_fixtures(connection, keys_to_fetch = nil) - if keys_to_fetch - cache_for_connection(connection).values_at(*keys_to_fetch) - else - cache_for_connection(connection).values - end - end - - def self.cache_fixtures(connection, fixtures_map) - cache_for_connection(connection).update(fixtures_map) - end - - def self.instantiate_fixtures(object, fixture_set, load_instances = true) - if load_instances - fixture_set.each do |fixture_name, fixture| - begin - object.instance_variable_set "@#{fixture_name}", fixture.find - rescue FixtureClassNotFound - nil - end - end - end - end - - def self.instantiate_all_loaded_fixtures(object, load_instances = true) - all_loaded_fixtures.each_value do |fixture_set| - instantiate_fixtures(object, fixture_set, load_instances) - end - end - cattr_accessor :all_loaded_fixtures, default: {} class ClassCache @@ -502,14 +451,16 @@ module ActiveRecord @config = config # Remove string values that aren't constants or subclasses of AR - @class_names.delete_if { |klass_name, klass| !insert_class(@class_names, klass_name, klass) } + @class_names.delete_if do |klass_name, klass| + !insert_class(@class_names, klass_name, klass) + end end def [](fs_name) - @class_names.fetch(fs_name) { + @class_names.fetch(fs_name) do klass = default_fixture_model(fs_name, @config).safe_constantize insert_class(@class_names, fs_name, klass) - } + end end private @@ -528,76 +479,146 @@ module ActiveRecord end end - def self.create_fixtures(fixtures_directory, fixture_set_names, class_names = {}, config = ActiveRecord::Base) - fixture_set_names = Array(fixture_set_names).map(&:to_s) - class_names = ClassCache.new class_names, config + class << self + def default_fixture_model_name(fixture_set_name, config = ActiveRecord::Base) # :nodoc: + config.pluralize_table_names ? + fixture_set_name.singularize.camelize : + fixture_set_name.camelize + end - # FIXME: Apparently JK uses this. - connection = block_given? ? yield : ActiveRecord::Base.connection + def default_fixture_table_name(fixture_set_name, config = ActiveRecord::Base) # :nodoc: + "#{ config.table_name_prefix }"\ + "#{ fixture_set_name.tr('/', '_') }"\ + "#{ config.table_name_suffix }".to_sym + end - files_to_read = fixture_set_names.reject { |fs_name| - fixture_is_cached?(connection, fs_name) - } + def reset_cache + @@all_cached_fixtures.clear + end - unless files_to_read.empty? - fixtures_map = {} + def cache_for_connection(connection) + @@all_cached_fixtures[connection] + end - fixture_sets = files_to_read.map do |fs_name| - klass = class_names[fs_name] - conn = klass ? klass.connection : connection - fixtures_map[fs_name] = new( # ActiveRecord::FixtureSet.new - conn, - fs_name, - klass, - ::File.join(fixtures_directory, fs_name)) + def fixture_is_cached?(connection, table_name) + cache_for_connection(connection)[table_name] + end + + def cached_fixtures(connection, keys_to_fetch = nil) + if keys_to_fetch + cache_for_connection(connection).values_at(*keys_to_fetch) + else + cache_for_connection(connection).values end + end - update_all_loaded_fixtures fixtures_map - fixture_sets_by_connection = fixture_sets.group_by { |fs| fs.model_class ? fs.model_class.connection : connection } + def cache_fixtures(connection, fixtures_map) + cache_for_connection(connection).update(fixtures_map) + end - fixture_sets_by_connection.each do |conn, set| - table_rows_for_connection = Hash.new { |h, k| h[k] = [] } + def instantiate_fixtures(object, fixture_set, load_instances = true) + return unless load_instances + fixture_set.each do |fixture_name, fixture| + object.instance_variable_set "@#{fixture_name}", fixture.find + rescue FixtureClassNotFound + nil + end + end - set.each do |fs| - fs.table_rows.each do |table, rows| - table_rows_for_connection[table].unshift(*rows) - end - end - conn.insert_fixtures_set(table_rows_for_connection, table_rows_for_connection.keys) + def instantiate_all_loaded_fixtures(object, load_instances = true) + all_loaded_fixtures.each_value do |fixture_set| + instantiate_fixtures(object, fixture_set, load_instances) + end + end - # Cap primary key sequences to max(pk). - if conn.respond_to?(:reset_pk_sequence!) - set.each { |fs| conn.reset_pk_sequence!(fs.table_name) } - end + def create_fixtures(fixtures_directory, fixture_set_names, class_names = {}, config = ActiveRecord::Base) + fixture_set_names = Array(fixture_set_names).map(&:to_s) + class_names = ClassCache.new class_names, config + + # FIXME: Apparently JK uses this. + connection = block_given? ? yield : ActiveRecord::Base.connection + + fixture_files_to_read = fixture_set_names.reject do |fs_name| + fixture_is_cached?(connection, fs_name) end - cache_fixtures(connection, fixtures_map) + if fixture_files_to_read.any? + fixtures_map = read_and_insert( + fixtures_directory, + fixture_files_to_read, + class_names, + connection, + ) + cache_fixtures(connection, fixtures_map) + end + cached_fixtures(connection, fixture_set_names) end - cached_fixtures(connection, fixture_set_names) - end - # Returns a consistent, platform-independent identifier for +label+. - # Integer identifiers are values less than 2^30. UUIDs are RFC 4122 version 5 SHA-1 hashes. - def self.identify(label, column_type = :integer) - if column_type == :uuid - Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, label.to_s) - else - Zlib.crc32(label.to_s) % MAX_ID + # Returns a consistent, platform-independent identifier for +label+. + # Integer identifiers are values less than 2^30. UUIDs are RFC 4122 version 5 SHA-1 hashes. + def identify(label, column_type = :integer) + if column_type == :uuid + Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, label.to_s) + else + Zlib.crc32(label.to_s) % MAX_ID + end end - end - # Superclass for the evaluation contexts used by ERB fixtures. - def self.context_class - @context_class ||= Class.new - end + # Superclass for the evaluation contexts used by ERB fixtures. + def context_class + @context_class ||= Class.new + end - def self.update_all_loaded_fixtures(fixtures_map) # :nodoc: - all_loaded_fixtures.update(fixtures_map) + private + + def read_and_insert(fixtures_directory, fixture_files, class_names, connection) # :nodoc: + fixtures_map = {} + fixture_sets = fixture_files.map do |fixture_set_name| + klass = class_names[fixture_set_name] + fixtures_map[fixture_set_name] = new( # ActiveRecord::FixtureSet.new + nil, + fixture_set_name, + klass, + ::File.join(fixtures_directory, fixture_set_name) + ) + end + update_all_loaded_fixtures(fixtures_map) + + insert(fixture_sets, connection) + + fixtures_map + end + + def insert(fixture_sets, connection) # :nodoc: + fixture_sets_by_connection = fixture_sets.group_by do |fixture_set| + fixture_set.model_class&.connection || connection + end + + fixture_sets_by_connection.each do |conn, set| + table_rows_for_connection = Hash.new { |h, k| h[k] = [] } + + set.each do |fixture_set| + fixture_set.table_rows.each do |table, rows| + table_rows_for_connection[table].unshift(*rows) + end + end + conn.insert_fixtures_set(table_rows_for_connection, table_rows_for_connection.keys) + + # Cap primary key sequences to max(pk). + if conn.respond_to?(:reset_pk_sequence!) + set.each { |fs| conn.reset_pk_sequence!(fs.table_name) } + end + end + end + + def update_all_loaded_fixtures(fixtures_map) # :nodoc: + all_loaded_fixtures.update(fixtures_map) + end end attr_reader :table_name, :name, :fixtures, :model_class, :config - def initialize(connection, name, class_name, path, config = ActiveRecord::Base) + def initialize(_, name, class_name, path, config = ActiveRecord::Base) @name = name @path = path @config = config @@ -606,11 +627,7 @@ module ActiveRecord @fixtures = read_fixture_files(path) - @connection = connection - - @table_name = (model_class.respond_to?(:table_name) ? - model_class.table_name : - self.class.default_fixture_table_name(name, config)) + @table_name = model_class&.table_name || self.class.default_fixture_table_name(name, config) end def [](x) @@ -632,152 +649,18 @@ module ActiveRecord # Returns a hash of rows to be inserted. The key is the table, the value is # a list of rows to insert to that table. def table_rows - now = config.default_timezone == :utc ? Time.now.utc : Time.now - # allow a standard key to be used for doing defaults in YAML fixtures.delete("DEFAULTS") - # track any join tables we need to insert later - rows = Hash.new { |h, table| h[table] = [] } - - rows[table_name] = fixtures.map do |label, fixture| - row = fixture.to_hash - - if model_class - # fill in timestamp columns if they aren't specified and the model is set to record_timestamps - if model_class.record_timestamps - timestamp_column_names.each do |c_name| - row[c_name] = now unless row.key?(c_name) - end - end - - # interpolate the fixture label - row.each do |key, value| - row[key] = value.gsub("$LABEL", label.to_s) if value.is_a?(String) - end - - # generate a primary key if necessary - if has_primary_key_column? && !row.include?(primary_key_name) - row[primary_key_name] = ActiveRecord::FixtureSet.identify(label, primary_key_type) - end - - # Resolve enums - model_class.defined_enums.each do |name, values| - if row.include?(name) - row[name] = values.fetch(row[name], row[name]) - end - end - - # If STI is used, find the correct subclass for association reflection - reflection_class = - if row.include?(inheritance_column_name) - row[inheritance_column_name].constantize rescue model_class - else - model_class - end - - reflection_class._reflections.each_value do |association| - case association.macro - when :belongs_to - # Do not replace association name with association foreign key if they are named the same - fk_name = (association.options[:foreign_key] || "#{association.name}_id").to_s - - if association.name.to_s != fk_name && value = row.delete(association.name.to_s) - if association.polymorphic? && value.sub!(/\s*\(([^\)]*)\)\s*$/, "") - # support polymorphic belongs_to as "label (Type)" - row[association.foreign_type] = $1 - end - - fk_type = reflection_class.type_for_attribute(fk_name).type - row[fk_name] = ActiveRecord::FixtureSet.identify(value, fk_type) - end - when :has_many - if association.options[:through] - add_join_records(rows, row, HasManyThroughProxy.new(association)) - end - end - end - end - - row - end - rows - end - - class ReflectionProxy # :nodoc: - def initialize(association) - @association = association - end - - def join_table - @association.join_table - end - - def name - @association.name - end - - def primary_key_type - @association.klass.type_for_attribute(@association.klass.primary_key).type - end - end - - class HasManyThroughProxy < ReflectionProxy # :nodoc: - def rhs_key - @association.foreign_key - end - - def lhs_key - @association.through_reflection.foreign_key - end - - def join_table - @association.through_reflection.table_name - end + TableRows.new( + table_name, + model_class: model_class, + fixtures: fixtures, + config: config, + ).to_hash end private - def primary_key_name - @primary_key_name ||= model_class && model_class.primary_key - end - - def primary_key_type - @primary_key_type ||= model_class && model_class.type_for_attribute(model_class.primary_key).type - end - - def add_join_records(rows, row, association) - # This is the case when the join table has no fixtures file - if (targets = row.delete(association.name.to_s)) - table_name = association.join_table - column_type = association.primary_key_type - lhs_key = association.lhs_key - rhs_key = association.rhs_key - - targets = targets.is_a?(Array) ? targets : targets.split(/\s*,\s*/) - rows[table_name].concat targets.map { |target| - { lhs_key => row[primary_key_name], - rhs_key => ActiveRecord::FixtureSet.identify(target, column_type) } - } - end - end - - def has_primary_key_column? - @has_primary_key_column ||= primary_key_name && - model_class.columns.any? { |c| c.name == primary_key_name } - end - - def timestamp_column_names - @timestamp_column_names ||= - %w(created_at created_on updated_at updated_on) & column_names - end - - def inheritance_column_name - @inheritance_column_name ||= model_class && model_class.inheritance_column - end - - def column_names - @column_names ||= @connection.columns(@table_name).collect(&:name) - end def model_class=(class_name) if class_name.is_a?(Class) # TODO: Should be an AR::Base type class, or any? @@ -841,225 +724,9 @@ module ActiveRecord alias :to_hash :fixture def find - if model_class - model_class.unscoped do - model_class.find(fixture[model_class.primary_key]) - end - else - raise FixtureClassNotFound, "No class attached to find." - end - end - end -end - -module ActiveRecord - module TestFixtures - extend ActiveSupport::Concern - - def before_setup # :nodoc: - setup_fixtures - super - end - - def after_teardown # :nodoc: - super - teardown_fixtures - end - - included do - class_attribute :fixture_path, instance_writer: false - class_attribute :fixture_table_names, default: [] - class_attribute :fixture_class_names, default: {} - class_attribute :use_transactional_tests, default: true - class_attribute :use_instantiated_fixtures, default: false # true, false, or :no_instances - class_attribute :pre_loaded_fixtures, default: false - class_attribute :config, default: ActiveRecord::Base - class_attribute :lock_threads, default: true - end - - module ClassMethods - # Sets the model class for a fixture when the class name cannot be inferred from the fixture name. - # - # Examples: - # - # set_fixture_class some_fixture: SomeModel, - # 'namespaced/fixture' => Another::Model - # - # The keys must be the fixture names, that coincide with the short paths to the fixture files. - def set_fixture_class(class_names = {}) - self.fixture_class_names = fixture_class_names.merge(class_names.stringify_keys) - end - - def fixtures(*fixture_set_names) - if fixture_set_names.first == :all - fixture_set_names = Dir["#{fixture_path}/{**,*}/*.{yml}"].uniq - fixture_set_names.map! { |f| f[(fixture_path.to_s.size + 1)..-5] } - else - fixture_set_names = fixture_set_names.flatten.map(&:to_s) - end - - self.fixture_table_names |= fixture_set_names - setup_fixture_accessors(fixture_set_names) - end - - def setup_fixture_accessors(fixture_set_names = nil) - fixture_set_names = Array(fixture_set_names || fixture_table_names) - methods = Module.new do - fixture_set_names.each do |fs_name| - fs_name = fs_name.to_s - accessor_name = fs_name.tr("/", "_").to_sym - - define_method(accessor_name) do |*fixture_names| - force_reload = fixture_names.pop if fixture_names.last == true || fixture_names.last == :reload - return_single_record = fixture_names.size == 1 - fixture_names = @loaded_fixtures[fs_name].fixtures.keys if fixture_names.empty? - - @fixture_cache[fs_name] ||= {} - - instances = fixture_names.map do |f_name| - f_name = f_name.to_s if f_name.is_a?(Symbol) - @fixture_cache[fs_name].delete(f_name) if force_reload - - if @loaded_fixtures[fs_name][f_name] - @fixture_cache[fs_name][f_name] ||= @loaded_fixtures[fs_name][f_name].find - else - raise StandardError, "No fixture named '#{f_name}' found for fixture set '#{fs_name}'" - end - end - - return_single_record ? instances.first : instances - end - private accessor_name - end - end - include methods - end - - def uses_transaction(*methods) - @uses_transaction = [] unless defined?(@uses_transaction) - @uses_transaction.concat methods.map(&:to_s) - end - - def uses_transaction?(method) - @uses_transaction = [] unless defined?(@uses_transaction) - @uses_transaction.include?(method.to_s) - end - end - - def run_in_transaction? - use_transactional_tests && - !self.class.uses_transaction?(method_name) - end - - def setup_fixtures(config = ActiveRecord::Base) - if pre_loaded_fixtures && !use_transactional_tests - raise RuntimeError, "pre_loaded_fixtures requires use_transactional_tests" - end - - @fixture_cache = {} - @fixture_connections = [] - @@already_loaded_fixtures ||= {} - @connection_subscriber = nil - - # Load fixtures once and begin transaction. - if run_in_transaction? - if @@already_loaded_fixtures[self.class] - @loaded_fixtures = @@already_loaded_fixtures[self.class] - else - @loaded_fixtures = load_fixtures(config) - @@already_loaded_fixtures[self.class] = @loaded_fixtures - end - - # Begin transactions for connections already established - @fixture_connections = enlist_fixture_connections - @fixture_connections.each do |connection| - connection.begin_transaction joinable: false - connection.pool.lock_thread = true if lock_threads - end - - # When connections are established in the future, begin a transaction too - @connection_subscriber = ActiveSupport::Notifications.subscribe("!connection.active_record") do |_, _, _, _, payload| - spec_name = payload[:spec_name] if payload.key?(:spec_name) - - if spec_name - begin - connection = ActiveRecord::Base.connection_handler.retrieve_connection(spec_name) - rescue ConnectionNotEstablished - connection = nil - end - - if connection && !@fixture_connections.include?(connection) - connection.begin_transaction joinable: false - connection.pool.lock_thread = true if lock_threads - @fixture_connections << connection - end - end - end - - # Load fixtures for every test. - else - ActiveRecord::FixtureSet.reset_cache - @@already_loaded_fixtures[self.class] = nil - @loaded_fixtures = load_fixtures(config) - end - - # Instantiate fixtures for every test if requested. - instantiate_fixtures if use_instantiated_fixtures - end - - def teardown_fixtures - # Rollback changes if a transaction is active. - if run_in_transaction? - ActiveSupport::Notifications.unsubscribe(@connection_subscriber) if @connection_subscriber - @fixture_connections.each do |connection| - connection.rollback_transaction if connection.transaction_open? - connection.pool.lock_thread = false - end - @fixture_connections.clear - else - ActiveRecord::FixtureSet.reset_cache - end - - ActiveRecord::Base.clear_active_connections! - end - - def enlist_fixture_connections - ActiveRecord::Base.connection_handler.connection_pool_list.map(&:connection) - end - - private - def load_fixtures(config) - fixtures = ActiveRecord::FixtureSet.create_fixtures(fixture_path, fixture_table_names, fixture_class_names, config) - Hash[fixtures.map { |f| [f.name, f] }] - end - - def instantiate_fixtures - if pre_loaded_fixtures - raise RuntimeError, "Load fixtures before instantiating them." if ActiveRecord::FixtureSet.all_loaded_fixtures.empty? - ActiveRecord::FixtureSet.instantiate_all_loaded_fixtures(self, load_instances?) - else - raise RuntimeError, "Load fixtures before instantiating them." if @loaded_fixtures.nil? - @loaded_fixtures.each_value do |fixture_set| - ActiveRecord::FixtureSet.instantiate_fixtures(self, fixture_set, load_instances?) - end - end - end - - def load_instances? - use_instantiated_fixtures != :no_instances - end - end -end - -class ActiveRecord::FixtureSet::RenderContext # :nodoc: - def self.create_subclass - Class.new ActiveRecord::FixtureSet.context_class do - def get_binding - binding() - end - - def binary(path) - %(!!binary "#{Base64.strict_encode64(File.read(path))}") + raise FixtureClassNotFound, "No class attached to find." unless model_class + model_class.unscoped do + model_class.find(fixture[model_class.primary_key]) end end end diff --git a/activerecord/lib/active_record/gem_version.rb b/activerecord/lib/active_record/gem_version.rb index 72035a986b..f77bc2e3c1 100644 --- a/activerecord/lib/active_record/gem_version.rb +++ b/activerecord/lib/active_record/gem_version.rb @@ -10,7 +10,7 @@ module ActiveRecord MAJOR = 6 MINOR = 0 TINY = 0 - PRE = "alpha" + PRE = "beta3" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activerecord/lib/active_record/inheritance.rb b/activerecord/lib/active_record/inheritance.rb index 6891c575c7..9570bc6f86 100644 --- a/activerecord/lib/active_record/inheritance.rb +++ b/activerecord/lib/active_record/inheritance.rb @@ -55,6 +55,10 @@ module ActiveRecord if has_attribute?(inheritance_column) subclass = subclass_from_attributes(attributes) + if subclass.nil? && scope_attributes = current_scope&.scope_for_create + subclass = subclass_from_attributes(scope_attributes) + end + if subclass.nil? && base_class? subclass = subclass_from_attributes(column_defaults) end @@ -176,7 +180,7 @@ module ActiveRecord # Returns the class type of the record using the current module as a prefix. So descendants of # MyApp::Business::Account would appear as MyApp::Business::AccountSubclass. def compute_type(type_name) - if type_name.start_with?("::".freeze) + if type_name.start_with?("::") # If the type is prefixed with a scope operator then we assume that # the type_name is an absolute reference. ActiveSupport::Dependencies.constantize(type_name) @@ -245,7 +249,7 @@ module ActiveRecord sti_column = arel_attribute(inheritance_column, table) sti_names = ([self] + descendants).map(&:sti_name) - sti_column.in(sti_names) + predicate_builder.build(sti_column, sti_names) end # Detect the subclass from the inheritance column of attrs. If the inheritance column value diff --git a/activerecord/lib/active_record/insert_all.rb b/activerecord/lib/active_record/insert_all.rb new file mode 100644 index 0000000000..959e5bd4d7 --- /dev/null +++ b/activerecord/lib/active_record/insert_all.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +module ActiveRecord + class InsertAll # :nodoc: + attr_reader :model, :connection, :inserts, :keys + attr_reader :on_duplicate, :returning, :unique_by + + def initialize(model, inserts, on_duplicate:, returning: nil, unique_by: nil) + raise ArgumentError, "Empty list of attributes passed" if inserts.blank? + + @model, @connection, @inserts, @keys = model, model.connection, inserts, inserts.first.keys.map(&:to_s).to_set + @on_duplicate, @returning, @unique_by = on_duplicate, returning, unique_by + + @returning = (connection.supports_insert_returning? ? primary_keys : false) if @returning.nil? + @returning = false if @returning == [] + + @unique_by = find_unique_index_for(unique_by) if unique_by + @on_duplicate = :skip if @on_duplicate == :update && updatable_columns.empty? + + ensure_valid_options_for_connection! + end + + def execute + message = "#{model} " + message += "Bulk " if inserts.many? + message += (on_duplicate == :update ? "Upsert" : "Insert") + connection.exec_query to_sql, message + end + + def updatable_columns + keys - readonly_columns - unique_by_columns + end + + def primary_keys + Array(model.primary_key) + end + + + def skip_duplicates? + on_duplicate == :skip + end + + def update_duplicates? + on_duplicate == :update + end + + def map_key_with_value + inserts.map do |attributes| + attributes = attributes.stringify_keys + verify_attributes(attributes) + + keys.map do |key| + yield key, attributes[key] + end + end + end + + private + def find_unique_index_for(unique_by) + match = Array(unique_by).map(&:to_s) + + if index = unique_indexes.find { |i| match.include?(i.name) || i.columns == match } + index + else + raise ArgumentError, "No unique index found for #{unique_by}" + end + end + + def unique_indexes + connection.schema_cache.indexes(model.table_name).select(&:unique) + end + + + def ensure_valid_options_for_connection! + if returning && !connection.supports_insert_returning? + raise ArgumentError, "#{connection.class} does not support :returning" + end + + if skip_duplicates? && !connection.supports_insert_on_duplicate_skip? + raise ArgumentError, "#{connection.class} does not support skipping duplicates" + end + + if update_duplicates? && !connection.supports_insert_on_duplicate_update? + raise ArgumentError, "#{connection.class} does not support upsert" + end + + if unique_by && !connection.supports_insert_conflict_target? + raise ArgumentError, "#{connection.class} does not support :unique_by" + end + end + + + def to_sql + connection.build_insert_sql(ActiveRecord::InsertAll::Builder.new(self)) + end + + + def readonly_columns + primary_keys + model.readonly_attributes.to_a + end + + def unique_by_columns + Array(unique_by&.columns) + end + + + def verify_attributes(attributes) + if keys != attributes.keys.to_set + raise ArgumentError, "All objects being inserted must have the same keys" + end + end + + + class Builder + attr_reader :model + + delegate :skip_duplicates?, :update_duplicates?, :keys, to: :insert_all + + def initialize(insert_all) + @insert_all, @model, @connection = insert_all, insert_all.model, insert_all.connection + end + + def into + "INTO #{model.quoted_table_name}(#{columns_list})" + end + + def values_list + types = extract_types_from_columns_on(model.table_name, keys: keys) + + values_list = insert_all.map_key_with_value do |key, value| + connection.with_yaml_fallback(types[key].serialize(value)) + end + + Arel::InsertManager.new.create_values_list(values_list).to_sql + end + + def returning + format_columns(insert_all.returning) if insert_all.returning + end + + def conflict_target + if index = insert_all.unique_by + sql = +"(#{format_columns(index.columns)})" + sql << " WHERE #{index.where}" if index.where + sql + elsif update_duplicates? + "(#{format_columns(insert_all.primary_keys)})" + end + end + + def updatable_columns + quote_columns(insert_all.updatable_columns) + end + + private + attr_reader :connection, :insert_all + + def columns_list + format_columns(insert_all.keys) + end + + def extract_types_from_columns_on(table_name, keys:) + columns = connection.schema_cache.columns_hash(table_name) + + unknown_column = (keys - columns.keys).first + raise UnknownAttributeError.new(model.new, unknown_column) if unknown_column + + keys.map { |key| [ key, connection.lookup_cast_type_from_column(columns[key]) ] }.to_h + end + + def format_columns(columns) + quote_columns(columns).join(",") + end + + def quote_columns(columns) + columns.map(&connection.method(:quote_column_name)) + end + end + end +end diff --git a/activerecord/lib/active_record/integration.rb b/activerecord/lib/active_record/integration.rb index 6cf26a9792..b769541e95 100644 --- a/activerecord/lib/active_record/integration.rb +++ b/activerecord/lib/active_record/integration.rb @@ -20,7 +20,7 @@ module ActiveRecord # Indicates whether to use a stable #cache_key method that is accompanied # by a changing version in the #cache_version method. # - # This is +false+, by default until Rails 6.0. + # This is +true+, by default on Rails 5.2 and above. class_attribute :cache_versioning, instance_writer: false, default: false end @@ -60,24 +60,15 @@ module ActiveRecord # the cache key will also include a version. # # Product.cache_versioning = false - # Person.find(5).cache_key # => "people/5-20071224150000" (updated_at available) - def cache_key(*timestamp_names) + # Product.find(5).cache_key # => "products/5-20071224150000" (updated_at available) + def cache_key if new_record? "#{model_name.cache_key}/new" else - if cache_version && timestamp_names.none? + if cache_version "#{model_name.cache_key}/#{id}" else - timestamp = if timestamp_names.any? - ActiveSupport::Deprecation.warn(<<-MSG.squish) - Specifying a timestamp name for #cache_key has been deprecated in favor of - the explicit #cache_version method that can be overwritten. - MSG - - max_updated_column_timestamp(timestamp_names) - else - max_updated_column_timestamp - end + timestamp = max_updated_column_timestamp if timestamp timestamp = timestamp.utc.to_s(cache_timestamp_format) @@ -96,8 +87,19 @@ module ActiveRecord # Note, this method will return nil if ActiveRecord::Base.cache_versioning is set to # +false+ (which it is by default until Rails 6.0). def cache_version - if cache_versioning && timestamp = try(:updated_at) - timestamp.utc.to_s(:usec) + return unless cache_versioning + + if has_attribute?("updated_at") + timestamp = updated_at_before_type_cast + if can_use_fast_cache_version?(timestamp) + raw_timestamp_to_cache_version(timestamp) + elsif timestamp = updated_at + timestamp.utc.to_s(cache_timestamp_format) + end + else + if self.class.has_attribute?("updated_at") + raise ActiveModel::MissingAttributeError, "missing attribute: updated_at" + end end end @@ -150,6 +152,48 @@ module ActiveRecord end end end + + def collection_cache_key(collection = all, timestamp_column = :updated_at) # :nodoc: + collection.compute_cache_key(timestamp_column) + end end + + private + # Detects if the value before type cast + # can be used to generate a cache_version. + # + # The fast cache version only works with a + # string value directly from the database. + # + # We also must check if the timestamp format has been changed + # or if the timezone is not set to UTC then + # we cannot apply our transformations correctly. + def can_use_fast_cache_version?(timestamp) + timestamp.is_a?(String) && + cache_timestamp_format == :usec && + default_timezone == :utc && + !updated_at_came_from_user? + end + + # Converts a raw database string to `:usec` + # format. + # + # Example: + # + # timestamp = "2018-10-15 20:02:15.266505" + # raw_timestamp_to_cache_version(timestamp) + # # => "20181015200215266505" + # + # PostgreSQL truncates trailing zeros, + # https://github.com/postgres/postgres/commit/3e1beda2cde3495f41290e1ece5d544525810214 + # to account for this we pad the output with zeros + def raw_timestamp_to_cache_version(timestamp) + key = timestamp.delete("- :.") + if key.length < 20 + key.ljust(20, "0") + else + key + end + end end end diff --git a/activerecord/lib/active_record/internal_metadata.rb b/activerecord/lib/active_record/internal_metadata.rb index 3626a13d7c..e6166581f1 100644 --- a/activerecord/lib/active_record/internal_metadata.rb +++ b/activerecord/lib/active_record/internal_metadata.rb @@ -8,12 +8,16 @@ module ActiveRecord # as which environment migrations were run in. class InternalMetadata < ActiveRecord::Base # :nodoc: class << self + def _internal? + true + end + def primary_key "key" end def table_name - "#{table_name_prefix}#{ActiveRecord::Base.internal_metadata_table_name}#{table_name_suffix}" + "#{table_name_prefix}#{internal_metadata_table_name}#{table_name_suffix}" end def []=(key, value) @@ -40,6 +44,10 @@ module ActiveRecord end end end + + def drop_table + connection.drop_table table_name, if_exists: true + end end end end diff --git a/activerecord/lib/active_record/locking/optimistic.rb b/activerecord/lib/active_record/locking/optimistic.rb index 7f096bb532..4a3a31fc95 100644 --- a/activerecord/lib/active_record/locking/optimistic.rb +++ b/activerecord/lib/active_record/locking/optimistic.rb @@ -61,7 +61,7 @@ module ActiveRecord end private - def _create_record(attribute_names = self.attribute_names, *) + def _create_record(attribute_names = self.attribute_names) if locking_enabled? # We always want to persist the locking version, even if we don't detect # a change from the default, since the database might have no default @@ -165,7 +165,7 @@ module ActiveRecord def inherited(subclass) subclass.class_eval do is_lock_column = ->(name, _) { lock_optimistically && name == locking_column } - decorate_matching_attribute_types(is_lock_column, :_optimistic_locking) do |type| + decorate_matching_attribute_types(is_lock_column, "_optimistic_locking") do |type| LockingType.new(type) end end diff --git a/activerecord/lib/active_record/locking/pessimistic.rb b/activerecord/lib/active_record/locking/pessimistic.rb index 5d1d15c94d..130ef8a330 100644 --- a/activerecord/lib/active_record/locking/pessimistic.rb +++ b/activerecord/lib/active_record/locking/pessimistic.rb @@ -14,9 +14,9 @@ module ActiveRecord # of your own such as 'LOCK IN SHARE MODE' or 'FOR UPDATE NOWAIT'. Example: # # Account.transaction do - # # select * from accounts where name = 'shugo' limit 1 for update - # shugo = Account.where("name = 'shugo'").lock(true).first - # yuko = Account.where("name = 'yuko'").lock(true).first + # # select * from accounts where name = 'shugo' limit 1 for update nowait + # shugo = Account.lock("FOR UPDATE NOWAIT").find_by(name: "shugo") + # yuko = Account.lock("FOR UPDATE NOWAIT").find_by(name: "yuko") # shugo.balance -= 100 # shugo.save! # yuko.balance += 100 diff --git a/activerecord/lib/active_record/log_subscriber.rb b/activerecord/lib/active_record/log_subscriber.rb index 9234029c22..6b84431343 100644 --- a/activerecord/lib/active_record/log_subscriber.rb +++ b/activerecord/lib/active_record/log_subscriber.rb @@ -4,6 +4,8 @@ module ActiveRecord class LogSubscriber < ActiveSupport::LogSubscriber IGNORE_PAYLOAD_NAMES = ["SCHEMA", "EXPLAIN"] + class_attribute :backtrace_cleaner, default: ActiveSupport::BacktraceCleaner.new + def self.runtime=(value) ActiveRecord::RuntimeRegistry.sql_runtime = value end @@ -100,36 +102,15 @@ module ActiveRecord end def log_query_source - source_line, line_number = extract_callstack(caller_locations) - - if source_line - if defined?(::Rails.root) - app_root = "#{::Rails.root.to_s}/".freeze - source_line = source_line.sub(app_root, "") - end - - logger.debug(" ↳ #{ source_line }:#{ line_number }") - end - end + source = extract_query_source_location(caller) - def extract_callstack(callstack) - line = callstack.find do |frame| - frame.absolute_path && !ignored_callstack(frame.absolute_path) + if source + logger.debug(" ↳ #{source}") end - - offending_line = line || callstack.first - - [ - offending_line.path, - offending_line.lineno - ] end - RAILS_GEM_ROOT = File.expand_path("../../../..", __FILE__) + "/" - - def ignored_callstack(path) - path.start_with?(RAILS_GEM_ROOT) || - path.start_with?(RbConfig::CONFIG["rubylibdir"]) + def extract_query_source_location(locations) + backtrace_cleaner.clean(locations).first end end end diff --git a/activerecord/lib/active_record/middleware/database_selector.rb b/activerecord/lib/active_record/middleware/database_selector.rb new file mode 100644 index 0000000000..b5b5df074c --- /dev/null +++ b/activerecord/lib/active_record/middleware/database_selector.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "active_record/middleware/database_selector/resolver" + +module ActiveRecord + module Middleware + # The DatabaseSelector Middleware provides a framework for automatically + # swapping from the primary to the replica database connection. Rails + # provides a basic framework to determine when to swap and allows for + # applications to write custom strategy classes to override the default + # behavior. + # + # The resolver class defines when the application should switch (i.e. read + # from the primary if a write occurred less than 2 seconds ago) and a + # resolver context class that sets a value that helps the resolver class + # decide when to switch. + # + # Rails default middleware uses the request's session to set a timestamp + # that informs the application when to read from a primary or read from a + # replica. + # + # To use the DatabaseSelector in your application with default settings add + # the following options to your environment config: + # + # config.active_record.database_selector = { delay: 2.seconds } + # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver + # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session + # + # New applications will include these lines commented out in the production.rb. + # + # The default behavior can be changed by setting the config options to a + # custom class: + # + # config.active_record.database_selector = { delay: 2.seconds } + # config.active_record.database_resolver = MyResolver + # config.active_record.database_resolver_context = MyResolver::MySession + class DatabaseSelector + def initialize(app, resolver_klass = Resolver, context_klass = Resolver::Session, options = {}) + @app = app + @resolver_klass = resolver_klass + @context_klass = context_klass + @options = options + end + + attr_reader :resolver_klass, :context_klass, :options + + # Middleware that determines which database connection to use in a multiple + # database application. + def call(env) + request = ActionDispatch::Request.new(env) + + select_database(request) do + @app.call(env) + end + end + + private + + def select_database(request, &blk) + context = context_klass.call(request) + resolver = resolver_klass.call(context, options) + + if reading_request?(request) + resolver.read(&blk) + else + resolver.write(&blk) + end + end + + def reading_request?(request) + request.get? || request.head? + end + end + end +end diff --git a/activerecord/lib/active_record/middleware/database_selector/resolver.rb b/activerecord/lib/active_record/middleware/database_selector/resolver.rb new file mode 100644 index 0000000000..80b8cd7cae --- /dev/null +++ b/activerecord/lib/active_record/middleware/database_selector/resolver.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "active_record/middleware/database_selector/resolver/session" + +module ActiveRecord + module Middleware + class DatabaseSelector + # The Resolver class is used by the DatabaseSelector middleware to + # determine which database the request should use. + # + # To change the behavior of the Resolver class in your application, + # create a custom resolver class that inherits from + # DatabaseSelector::Resolver and implements the methods that need to + # be changed. + # + # By default the Resolver class will send read traffic to the replica + # if it's been 2 seconds since the last write. + class Resolver # :nodoc: + SEND_TO_REPLICA_DELAY = 2.seconds + + def self.call(context, options = {}) + new(context, options) + end + + def initialize(context, options = {}) + @context = context + @options = options + @delay = @options && @options[:delay] ? @options[:delay] : SEND_TO_REPLICA_DELAY + @instrumenter = ActiveSupport::Notifications.instrumenter + end + + attr_reader :context, :delay, :instrumenter + + def read(&blk) + if read_from_primary? + read_from_primary(&blk) + else + read_from_replica(&blk) + end + end + + def write(&blk) + write_to_primary(&blk) + end + + private + + def read_from_primary(&blk) + ActiveRecord::Base.connection.while_preventing_writes do + ActiveRecord::Base.connected_to(role: ActiveRecord::Base.writing_role) do + instrumenter.instrument("database_selector.active_record.read_from_primary") do + yield + end + end + end + end + + def read_from_replica(&blk) + ActiveRecord::Base.connected_to(role: ActiveRecord::Base.reading_role) do + instrumenter.instrument("database_selector.active_record.read_from_replica") do + yield + end + end + end + + def write_to_primary(&blk) + ActiveRecord::Base.connected_to(role: ActiveRecord::Base.writing_role) do + instrumenter.instrument("database_selector.active_record.wrote_to_primary") do + yield + ensure + context.update_last_write_timestamp + end + end + end + + def read_from_primary? + !time_since_last_write_ok? + end + + def send_to_replica_delay + delay + end + + def time_since_last_write_ok? + Time.now - context.last_write_timestamp >= send_to_replica_delay + end + end + end + end +end diff --git a/activerecord/lib/active_record/middleware/database_selector/resolver/session.rb b/activerecord/lib/active_record/middleware/database_selector/resolver/session.rb new file mode 100644 index 0000000000..df7af054b7 --- /dev/null +++ b/activerecord/lib/active_record/middleware/database_selector/resolver/session.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module ActiveRecord + module Middleware + class DatabaseSelector + class Resolver + # The session class is used by the DatabaseSelector::Resolver to save + # timestamps of the last write in the session. + # + # The last_write is used to determine whether it's safe to read + # from the replica or the request needs to be sent to the primary. + class Session # :nodoc: + def self.call(request) + new(request.session) + end + + # Converts time to a timestamp that represents milliseconds since + # epoch. + def self.convert_time_to_timestamp(time) + time.to_i * 1000 + time.usec / 1000 + end + + # Converts milliseconds since epoch timestamp into a time object. + def self.convert_timestamp_to_time(timestamp) + timestamp ? Time.at(timestamp / 1000, (timestamp % 1000) * 1000) : Time.at(0) + end + + def initialize(session) + @session = session + end + + attr_reader :session + + def last_write_timestamp + self.class.convert_timestamp_to_time(session[:last_write]) + end + + def update_last_write_timestamp + session[:last_write] = self.class.convert_time_to_timestamp(Time.now) + end + end + end + end + end +end diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index 663b3c590a..ed0c6d48b8 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true +require "benchmark" require "set" require "zlib" require "active_support/core_ext/module/attribute_accessors" module ActiveRecord - class MigrationError < ActiveRecordError#:nodoc: + class MigrationError < ActiveRecordError #:nodoc: def initialize(message = nil) message = "\n\n#{message}\n\n" if message super @@ -22,7 +23,7 @@ module ActiveRecord # t.string :zipcode # end # - # execute <<-SQL + # execute <<~SQL # ALTER TABLE distributors # ADD CONSTRAINT zipchk # CHECK (char_length(zipcode) = 5) NO INHERIT; @@ -40,7 +41,7 @@ module ActiveRecord # t.string :zipcode # end # - # execute <<-SQL + # execute <<~SQL # ALTER TABLE distributors # ADD CONSTRAINT zipchk # CHECK (char_length(zipcode) = 5) NO INHERIT; @@ -48,7 +49,7 @@ module ActiveRecord # end # # def down - # execute <<-SQL + # execute <<~SQL # ALTER TABLE distributors # DROP CONSTRAINT zipchk # SQL @@ -67,7 +68,7 @@ module ActiveRecord # # reversible do |dir| # dir.up do - # execute <<-SQL + # execute <<~SQL # ALTER TABLE distributors # ADD CONSTRAINT zipchk # CHECK (char_length(zipcode) = 5) NO INHERIT; @@ -75,7 +76,7 @@ module ActiveRecord # end # # dir.down do - # execute <<-SQL + # execute <<~SQL # ALTER TABLE distributors # DROP CONSTRAINT zipchk # SQL @@ -86,7 +87,7 @@ module ActiveRecord class IrreversibleMigration < MigrationError end - class DuplicateMigrationVersionError < MigrationError#:nodoc: + class DuplicateMigrationVersionError < MigrationError #:nodoc: def initialize(version = nil) if version super("Multiple migrations have the version number #{version}.") @@ -96,7 +97,7 @@ module ActiveRecord end end - class DuplicateMigrationNameError < MigrationError#:nodoc: + class DuplicateMigrationNameError < MigrationError #:nodoc: def initialize(name = nil) if name super("Multiple migrations have the name #{name}.") @@ -116,7 +117,7 @@ module ActiveRecord end end - class IllegalMigrationNameError < MigrationError#:nodoc: + class IllegalMigrationNameError < MigrationError #:nodoc: def initialize(name = nil) if name super("Illegal name for migration file: #{name}\n\t(only lower case letters, numbers, and '_' allowed).") @@ -126,12 +127,12 @@ module ActiveRecord end end - class PendingMigrationError < MigrationError#:nodoc: + class PendingMigrationError < MigrationError #:nodoc: def initialize(message = nil) if !message && defined?(Rails.env) - super("Migrations are pending. To resolve this issue, run:\n\n bin/rails db:migrate RAILS_ENV=#{::Rails.env}") + super("Migrations are pending. To resolve this issue, run:\n\n rails db:migrate RAILS_ENV=#{::Rails.env}") elsif !message - super("Migrations are pending. To resolve this issue, run:\n\n bin/rails db:migrate") + super("Migrations are pending. To resolve this issue, run:\n\n rails db:migrate") else super end @@ -139,8 +140,8 @@ module ActiveRecord end class ConcurrentMigrationError < MigrationError #:nodoc: - DEFAULT_MESSAGE = "Cannot run migrations because another migration process is currently running.".freeze - RELEASE_LOCK_FAILED_MESSAGE = "Failed to release advisory lock".freeze + DEFAULT_MESSAGE = "Cannot run migrations because another migration process is currently running." + RELEASE_LOCK_FAILED_MESSAGE = "Failed to release advisory lock" def initialize(message = DEFAULT_MESSAGE) super @@ -149,7 +150,7 @@ module ActiveRecord class NoEnvironmentInSchemaError < MigrationError #:nodoc: def initialize - msg = "Environment data not found in the schema. To resolve this issue, run: \n\n bin/rails db:environment:set" + msg = "Environment data not found in the schema. To resolve this issue, run: \n\n rails db:environment:set" if defined?(Rails.env) super("#{msg} RAILS_ENV=#{::Rails.env}") else @@ -160,7 +161,7 @@ module ActiveRecord class ProtectedEnvironmentError < ActiveRecordError #:nodoc: def initialize(env = "production") - msg = "You are attempting to run a destructive action against your '#{env}' database.\n".dup + msg = +"You are attempting to run a destructive action against your '#{env}' database.\n" msg << "If you are sure you want to continue, run the same command with the environment variable:\n" msg << "DISABLE_DATABASE_ENVIRONMENT_CHECK=1" super(msg) @@ -169,10 +170,10 @@ module ActiveRecord class EnvironmentMismatchError < ActiveRecordError def initialize(current: nil, stored: nil) - msg = "You are attempting to modify a database that was last run in `#{ stored }` environment.\n".dup + msg = +"You are attempting to modify a database that was last run in `#{ stored }` environment.\n" msg << "You are running in `#{ current }` environment. " msg << "If you are sure you want to continue, first set the environment using:\n\n" - msg << " bin/rails db:environment:set" + msg << " rails db:environment:set" if defined?(Rails.env) super("#{msg} RAILS_ENV=#{::Rails.env}\n\n") else @@ -307,7 +308,7 @@ module ActiveRecord # named +column_name+ from the table called +table_name+. # * <tt>remove_columns(table_name, *column_names)</tt>: Removes the given # columns from the table definition. - # * <tt>remove_foreign_key(from_table, options_or_to_table)</tt>: Removes the + # * <tt>remove_foreign_key(from_table, to_table = nil, **options)</tt>: Removes the # given foreign key from the table called +table_name+. # * <tt>remove_index(table_name, column: column_names)</tt>: Removes the index # specified by +column_names+. @@ -351,7 +352,7 @@ module ActiveRecord # <tt>rails db:migrate</tt>. This will update the database by running all of the # pending migrations, creating the <tt>schema_migrations</tt> table # (see "About the schema_migrations table" section below) if missing. It will also - # invoke the db:schema:dump task, which will update your db/schema.rb file + # invoke the db:schema:dump command, which will update your db/schema.rb file # to match the structure of your database. # # To roll the database back to a previous migration version, use @@ -519,10 +520,10 @@ module ActiveRecord autoload :Compatibility, "active_record/migration/compatibility" # This must be defined before the inherited hook, below - class Current < Migration # :nodoc: + class Current < Migration #:nodoc: end - def self.inherited(subclass) # :nodoc: + def self.inherited(subclass) #:nodoc: super if subclass.superclass == Migration raise StandardError, "Directly inheriting from ActiveRecord::Migration is not supported. " \ @@ -540,7 +541,7 @@ module ActiveRecord ActiveRecord::VERSION::STRING.to_f end - MigrationFilenameRegexp = /\A([0-9]+)_([_a-z0-9]*)\.?([_a-z0-9]*)?\.rb\z/ # :nodoc: + MigrationFilenameRegexp = /\A([0-9]+)_([_a-z0-9]*)\.?([_a-z0-9]*)?\.rb\z/ #:nodoc: # This class is used to verify that all migrations have been run before # loading a web page if <tt>config.active_record.migration_error</tt> is set to :page_load @@ -567,10 +568,10 @@ module ActiveRecord end class << self - attr_accessor :delegate # :nodoc: - attr_accessor :disable_ddl_transaction # :nodoc: + attr_accessor :delegate #:nodoc: + attr_accessor :disable_ddl_transaction #:nodoc: - def nearest_delegate # :nodoc: + def nearest_delegate #:nodoc: delegate || superclass.nearest_delegate end @@ -594,13 +595,13 @@ module ActiveRecord end end - def maintain_test_schema! # :nodoc: + def maintain_test_schema! #:nodoc: if ActiveRecord::Base.maintain_test_schema suppress_messages { load_schema_if_pending! } end end - def method_missing(name, *args, &block) # :nodoc: + def method_missing(name, *args, &block) #:nodoc: nearest_delegate.send(name, *args, &block) end @@ -617,7 +618,7 @@ module ActiveRecord end end - def disable_ddl_transaction # :nodoc: + def disable_ddl_transaction #:nodoc: self.class.disable_ddl_transaction end @@ -677,15 +678,13 @@ module ActiveRecord if connection.respond_to? :revert connection.revert { yield } else - recorder = CommandRecorder.new(connection) + recorder = command_recorder @connection = recorder suppress_messages do connection.revert { yield } end @connection = recorder.delegate - recorder.commands.each do |cmd, args, block| - send(cmd, *args, &block) - end + recorder.replay(self) end end end @@ -694,7 +693,7 @@ module ActiveRecord connection.respond_to?(:reverting) && connection.reverting end - ReversibleBlockHelper = Struct.new(:reverting) do # :nodoc: + ReversibleBlockHelper = Struct.new(:reverting) do #:nodoc: def up yield unless reverting end @@ -830,10 +829,14 @@ module ActiveRecord write "== %s %s" % [text, "=" * length] end + # Takes a message argument and outputs it as is. + # A second boolean argument can be passed to specify whether to indent or not. def say(message, subitem = false) write "#{subitem ? " ->" : "--"} #{message}" end + # Outputs text along with how long it took to run its block. + # If the block returns an integer it assumes it is the number of rows affected. def say_with_time(message) say(message) result = nil @@ -843,6 +846,7 @@ module ActiveRecord result end + # Takes a block as an argument and suppresses any output generated by the block. def suppress_messages save, self.verbose = verbose, false yield @@ -885,7 +889,7 @@ module ActiveRecord source_migrations.each do |migration| source = File.binread(migration.filename) inserted_comment = "# This migration comes from #{scope} (originally #{migration.version})\n" - magic_comments = "".dup + magic_comments = +"" loop do # If we have a magic comment in the original migration, # insert our comment after the first newline(end of the magic comment line) @@ -956,6 +960,10 @@ module ActiveRecord yield end end + + def command_recorder + CommandRecorder.new(connection) + end end # MigrationProxy is used to defer loading of the actual migration classes @@ -998,7 +1006,7 @@ module ActiveRecord end end - class MigrationContext # :nodoc: + class MigrationContext #:nodoc: attr_reader :migrations_paths def initialize(migrations_paths) @@ -1079,10 +1087,6 @@ module ActiveRecord migrations.last || NullMigration.new end - def parse_migration_filename(filename) # :nodoc: - File.basename(filename).scan(Migration::MigrationFilenameRegexp).first - end - def migrations migrations = migration_files.map do |file| version, name, scope = parse_migration_filename(file) @@ -1114,11 +1118,6 @@ module ActiveRecord (db_list + file_list).sort_by { |_, version, _| version } end - def migration_files - paths = Array(migrations_paths) - Dir[*paths.flat_map { |path| "#{path}/**/[0-9]*_*.rb" }] - end - def current_environment ActiveRecord::ConnectionHandling::DEFAULT_ENV.call end @@ -1137,6 +1136,15 @@ module ActiveRecord end private + def migration_files + paths = Array(migrations_paths) + Dir[*paths.flat_map { |path| "#{path}/**/[0-9]*_*.rb" }] + end + + def parse_migration_filename(filename) + File.basename(filename).scan(Migration::MigrationFilenameRegexp).first + end + def move(direction, steps) migrator = Migrator.new(direction, migrations) @@ -1157,17 +1165,10 @@ module ActiveRecord end end - class Migrator # :nodoc: + class Migrator #:nodoc: class << self attr_accessor :migrations_paths - def migrations_path=(path) - ActiveSupport::Deprecation.warn \ - "ActiveRecord::Migrator.migrations_paths= is now deprecated and will be removed in Rails 6.0." \ - "You can set the `migrations_paths` on the `connection` instead through the `database.yml`." - self.migrations_paths = [path] - end - # For cases where a table doesn't exist like loading from schema cache def current_version MigrationContext.new(migrations_paths).current_version @@ -1293,7 +1294,7 @@ module ActiveRecord record_version_state_after_migrating(migration.version) end rescue => e - msg = "An error has occurred, ".dup + msg = +"An error has occurred, " msg << "this and " if use_transaction?(migration) msg << "all later migrations canceled:\n\n#{e}" raise StandardError, msg, e.backtrace @@ -1322,7 +1323,7 @@ module ActiveRecord def record_version_state_after_migrating(version) if down? migrated.delete(version) - ActiveRecord::SchemaMigration.where(version: version.to_s).delete_all + ActiveRecord::SchemaMigration.delete_by(version: version.to_s) else migrated << version ActiveRecord::SchemaMigration.create!(version: version.to_s) @@ -1351,7 +1352,7 @@ module ActiveRecord end def use_advisory_lock? - Base.connection.supports_advisory_locks? + Base.connection.advisory_locks_enabled? end def with_advisory_lock diff --git a/activerecord/lib/active_record/migration/command_recorder.rb b/activerecord/lib/active_record/migration/command_recorder.rb index 087632b10f..8e7f596076 100644 --- a/activerecord/lib/active_record/migration/command_recorder.rb +++ b/activerecord/lib/active_record/migration/command_recorder.rb @@ -108,11 +108,17 @@ module ActiveRecord yield delegate.update_table_definition(table_name, self) end + def replay(migration) + commands.each do |cmd, args, block| + migration.send(cmd, *args, &block) + end + end + private module StraightReversions # :nodoc: private - { transaction: :transaction, + { execute_block: :execute_block, create_table: :drop_table, create_join_table: :drop_join_table, @@ -133,6 +139,17 @@ module ActiveRecord include StraightReversions + def invert_transaction(args) + sub_recorder = CommandRecorder.new(delegate) + sub_recorder.revert { yield } + + invertions_proc = proc { + sub_recorder.replay(self) + } + + [:transaction, args, invertions_proc] + end + def invert_drop_table(args, &block) if args.size == 1 && block == nil raise ActiveRecord::IrreversibleMigration, "To avoid mistakes, drop_table is only reversible if given options or a block (can be empty)." @@ -214,11 +231,15 @@ module ActiveRecord end def invert_remove_foreign_key(args) - from_table, to_table, remove_options = args - raise ActiveRecord::IrreversibleMigration, "remove_foreign_key is only reversible if given a second table" if to_table.nil? || to_table.is_a?(Hash) + options = args.extract_options! + from_table, to_table = args + + to_table ||= options.delete(:to_table) + + raise ActiveRecord::IrreversibleMigration, "remove_foreign_key is only reversible if given a second table" if to_table.nil? reversed_args = [from_table, to_table] - reversed_args << remove_options if remove_options + reversed_args << options unless options.empty? [:add_foreign_key, reversed_args] end diff --git a/activerecord/lib/active_record/migration/compatibility.rb b/activerecord/lib/active_record/migration/compatibility.rb index 0edaaa0cf9..abc939826b 100644 --- a/activerecord/lib/active_record/migration/compatibility.rb +++ b/activerecord/lib/active_record/migration/compatibility.rb @@ -16,24 +16,79 @@ module ActiveRecord V6_0 = Current class V5_2 < V6_0 + module TableDefinition + def timestamps(**options) + options[:precision] ||= nil + super + end + end + + module CommandRecorder + def invert_transaction(args, &block) + [:transaction, args, block] + end + end + + def create_table(table_name, **options) + if block_given? + super { |t| yield compatible_table_definition(t) } + else + super + end + end + + def change_table(table_name, **options) + if block_given? + super { |t| yield compatible_table_definition(t) } + else + super + end + end + + def create_join_table(table_1, table_2, **options) + if block_given? + super { |t| yield compatible_table_definition(t) } + else + super + end + end + + def add_timestamps(table_name, **options) + options[:precision] ||= nil + super + end + + private + def compatible_table_definition(t) + class << t + prepend TableDefinition + end + t + end + + def command_recorder + recorder = super + class << recorder + prepend CommandRecorder + end + recorder + end end 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) + if connection.adapter_name == "PostgreSQL" + super(table_name, column_name, type, options.except(:default, :null, :comment)) + connection.change_column_default(table_name, column_name, options[:default]) if options.key?(:default) + connection.change_column_null(table_name, column_name, options[:null], options[:default]) if options.key?(:null) + connection.change_column_comment(table_name, column_name, options[:comment]) if options.key?(:comment) else super end end def create_table(table_name, options = {}) - if adapter_name == "Mysql2" + if connection.adapter_name == "Mysql2" super(table_name, options: "ENGINE=InnoDB", **options) else super @@ -55,13 +110,13 @@ module ActiveRecord end def create_table(table_name, options = {}) - if adapter_name == "PostgreSQL" + if connection.adapter_name == "PostgreSQL" if options[:id] == :uuid && !options.key?(:default) options[:default] = "uuid_generate_v4()" end end - unless adapter_name == "Mysql2" && options[:id] == :bigint + unless connection.adapter_name == "Mysql2" && options[:id] == :bigint if [:integer, :bigint].include?(options[:id]) && !options.key?(:default) options[:default] = nil end @@ -74,35 +129,12 @@ module ActiveRecord options[:id] = :integer end - if block_given? - super do |t| - yield compatible_table_definition(t) - end - else - super - end - end - - def change_table(table_name, options = {}) - if block_given? - super do |t| - yield compatible_table_definition(t) - end - else - super - end + super end def create_join_table(table_1, table_2, column_options: {}, **options) column_options.reverse_merge!(type: :integer) - - if block_given? - super do |t| - yield compatible_table_definition(t) - end - else - super - end + super end def add_column(table_name, column_name, type, options = {}) @@ -123,7 +155,7 @@ module ActiveRecord class << t prepend TableDefinition end - t + super end end @@ -141,33 +173,13 @@ module ActiveRecord end end - def create_table(table_name, options = {}) - if block_given? - super do |t| - yield compatible_table_definition(t) - end - else - super - end - end - - def change_table(table_name, options = {}) - if block_given? - super do |t| - yield compatible_table_definition(t) - end - else - super - end - end - - def add_reference(*, **options) + def add_reference(table_name, ref_name, **options) options[:index] ||= false super end alias :add_belongs_to :add_reference - def add_timestamps(_, **options) + def add_timestamps(table_name, **options) options[:null] = true if options[:null].nil? super end @@ -178,7 +190,7 @@ module ActiveRecord if options[:name].present? options[:name].to_s else - index_name(table_name, column: column_names) + connection.index_name(table_name, column: column_names) end super end @@ -198,15 +210,17 @@ module ActiveRecord end def index_name_for_remove(table_name, options = {}) - index_name = index_name(table_name, options) + index_name = connection.index_name(table_name, options) - unless index_name_exists?(table_name, index_name) + unless connection.index_name_exists?(table_name, index_name) if options.is_a?(Hash) && options.has_key?(:name) options_without_column = options.dup options_without_column.delete :column - index_name_without_column = index_name(table_name, options_without_column) + index_name_without_column = connection.index_name(table_name, options_without_column) - return index_name_without_column if index_name_exists?(table_name, index_name_without_column) + if connection.index_name_exists?(table_name, index_name_without_column) + return index_name_without_column + end end raise ArgumentError, "Index name '#{index_name}' on table '#{table_name}' does not exist" diff --git a/activerecord/lib/active_record/model_schema.rb b/activerecord/lib/active_record/model_schema.rb index 694ff85fa1..55fc58e339 100644 --- a/activerecord/lib/active_record/model_schema.rb +++ b/activerecord/lib/active_record/model_schema.rb @@ -102,6 +102,21 @@ module ActiveRecord # If true, the default table name for a Product class will be "products". If false, it would just be "product". # See table_name for the full rules on table/class naming. This is true, by default. + ## + # :singleton-method: implicit_order_column + # :call-seq: implicit_order_column + # + # The name of the column records are ordered by if no explicit order clause + # is used during an ordered finder call. If not set the primary key is used. + + ## + # :singleton-method: implicit_order_column= + # :call-seq: implicit_order_column=(column_name) + # + # Sets the column to sort records by when no explicit order clause is used + # during an ordered finder call. Useful when the primary key is not an + # auto-incrementing integer, for example when it's a UUID. Note that using + # a non-unique column can result in non-deterministic results. included do mattr_accessor :primary_key_prefix_type, instance_writer: false @@ -110,6 +125,7 @@ module ActiveRecord class_attribute :schema_migrations_table_name, instance_accessor: false, default: "schema_migrations" class_attribute :internal_metadata_table_name, instance_accessor: false, default: "ar_internal_metadata" class_attribute :pluralize_table_names, instance_writer: false, default: true + class_attribute :implicit_order_column, instance_accessor: false self.protected_environments = ["production"] self.inheritance_column = "type" @@ -218,11 +234,11 @@ module ActiveRecord end def full_table_name_prefix #:nodoc: - (parents.detect { |p| p.respond_to?(:table_name_prefix) } || self).table_name_prefix + (module_parents.detect { |p| p.respond_to?(:table_name_prefix) } || self).table_name_prefix end def full_table_name_suffix #:nodoc: - (parents.detect { |p| p.respond_to?(:table_name_suffix) } || self).table_name_suffix + (module_parents.detect { |p| p.respond_to?(:table_name_suffix) } || self).table_name_suffix end # The array of names of environments where destructive actions should be prohibited. By default, @@ -375,7 +391,7 @@ module ActiveRecord # default values when instantiating the Active Record object for this table. def column_defaults load_schema - @column_defaults ||= _default_attributes.to_hash + @column_defaults ||= _default_attributes.deep_dup.to_hash end def _default_attributes # :nodoc: @@ -388,6 +404,11 @@ module ActiveRecord @column_names ||= columns.map(&:name) end + def symbol_column_to_string(name_symbol) # :nodoc: + @symbol_column_to_string_name_hash ||= column_names.index_by(&:to_sym) + @symbol_column_to_string_name_hash[name_symbol] + end + # Returns an array of column objects where the primary id, all columns ending in "_id" or "_count", # and columns used for single table inheritance have been removed. def content_columns @@ -477,6 +498,7 @@ module ActiveRecord def reload_schema_from_cache @arel_table = nil @column_names = nil + @symbol_column_to_string_name_hash = nil @attribute_types = nil @content_columns = nil @default_attributes = nil @@ -503,9 +525,9 @@ module ActiveRecord def compute_table_name if base_class? # Nested classes are prefixed with singular parent table name. - if parent < Base && !parent.abstract_class? - contained = parent.table_name - contained = contained.singularize if parent.pluralize_table_names + if module_parent < Base && !module_parent.abstract_class? + contained = module_parent.table_name + contained = contained.singularize if module_parent.pluralize_table_names contained += "_" end diff --git a/activerecord/lib/active_record/nested_attributes.rb b/activerecord/lib/active_record/nested_attributes.rb index fa20bce3a9..8b9098df6c 100644 --- a/activerecord/lib/active_record/nested_attributes.rb +++ b/activerecord/lib/active_record/nested_attributes.rb @@ -426,7 +426,7 @@ module ActiveRecord existing_record.assign_attributes(assignable_attributes) association(association_name).initialize_attributes(existing_record) else - method = "build_#{association_name}" + method = :"build_#{association_name}" if respond_to?(method) send(method, assignable_attributes) else @@ -501,7 +501,7 @@ module ActiveRecord if attributes["id"].blank? unless reject_new_record?(association_name, attributes) - association.build(attributes.except(*UNASSIGNABLE_KEYS)) + association.reader.build(attributes.except(*UNASSIGNABLE_KEYS)) end elsif existing_record = existing_records.detect { |record| record.id.to_s == attributes["id"].to_s } unless call_reject_if(association_name, attributes) diff --git a/activerecord/lib/active_record/no_touching.rb b/activerecord/lib/active_record/no_touching.rb index 754c891884..697076bdae 100644 --- a/activerecord/lib/active_record/no_touching.rb +++ b/activerecord/lib/active_record/no_touching.rb @@ -43,6 +43,13 @@ module ActiveRecord end end + # Returns +true+ if the class has +no_touching+ set, +false+ otherwise. + # + # Project.no_touching do + # Project.first.no_touching? # true + # Message.first.no_touching? # false + # end + # def no_touching? NoTouching.applied_to?(self.class) end diff --git a/activerecord/lib/active_record/persistence.rb b/activerecord/lib/active_record/persistence.rb index 7721e6b691..e05490753f 100644 --- a/activerecord/lib/active_record/persistence.rb +++ b/activerecord/lib/active_record/persistence.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "active_record/insert_all" + module ActiveRecord # = Active Record \Persistence module Persistence @@ -55,6 +57,192 @@ module ActiveRecord end end + # Inserts a single record into the database in a single SQL INSERT + # statement. It does not instantiate any models nor does it trigger + # Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # See <tt>ActiveRecord::Persistence#insert_all</tt> for documentation. + def insert(attributes, returning: nil, unique_by: nil) + insert_all([ attributes ], returning: returning, unique_by: unique_by) + end + + # Inserts multiple records into the database in a single SQL INSERT + # statement. It does not instantiate any models nor does it trigger + # Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # The +attributes+ parameter is an Array of Hashes. Every Hash determines + # the attributes for a single row and must have the same keys. + # + # Rows are considered to be unique by every unique index on the table. Any + # duplicate rows are skipped. + # Override with <tt>:unique_by</tt> (see below). + # + # Returns an <tt>ActiveRecord::Result</tt> with its contents based on + # <tt>:returning</tt> (see below). + # + # ==== Options + # + # [:returning] + # (PostgreSQL only) An array of attributes to return for all successfully + # inserted records, which by default is the primary key. + # Pass <tt>returning: %w[ id name ]</tt> for both id and name + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL + # clause entirely. + # + # [:unique_by] + # (PostgreSQL and SQLite only) By default rows are considered to be unique + # by every unique index on the table. Any duplicate rows are skipped. + # + # To skip rows according to just one unique index pass <tt>:unique_by</tt>. + # + # Consider a Book model where no duplicate ISBNs make sense, but if any + # row has an existing id, or is not unique by another unique index, + # <tt>ActiveRecord::RecordNotUnique</tt> is raised. + # + # Unique indexes can be identified by columns or name: + # + # unique_by: :isbn + # unique_by: %i[ author_id name ] + # unique_by: :index_books_on_isbn + # + # Because it relies on the index information from the database + # <tt>:unique_by</tt> is recommended to be paired with + # Active Record's schema_cache. + # + # ==== Example + # + # # Insert records and skip inserting any duplicates. + # # Here "Eloquent Ruby" is skipped because its id is not unique. + # + # Book.insert_all([ + # { id: 1, title: "Rework", author: "David" }, + # { id: 1, title: "Eloquent Ruby", author: "Russ" } + # ]) + def insert_all(attributes, returning: nil, unique_by: nil) + InsertAll.new(self, attributes, on_duplicate: :skip, returning: returning, unique_by: unique_by).execute + end + + # Inserts a single record into the database in a single SQL INSERT + # statement. It does not instantiate any models nor does it trigger + # Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # See <tt>ActiveRecord::Persistence#insert_all!</tt> for more. + def insert!(attributes, returning: nil) + insert_all!([ attributes ], returning: returning) + end + + # Inserts multiple records into the database in a single SQL INSERT + # statement. It does not instantiate any models nor does it trigger + # Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # The +attributes+ parameter is an Array of Hashes. Every Hash determines + # the attributes for a single row and must have the same keys. + # + # Raises <tt>ActiveRecord::RecordNotUnique</tt> if any rows violate a + # unique index on the table. In that case, no rows are inserted. + # + # To skip duplicate rows, see <tt>ActiveRecord::Persistence#insert_all</tt>. + # To replace them, see <tt>ActiveRecord::Persistence#upsert_all</tt>. + # + # Returns an <tt>ActiveRecord::Result</tt> with its contents based on + # <tt>:returning</tt> (see below). + # + # ==== Options + # + # [:returning] + # (PostgreSQL only) An array of attributes to return for all successfully + # inserted records, which by default is the primary key. + # Pass <tt>returning: %w[ id name ]</tt> for both id and name + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL + # clause entirely. + # + # ==== Examples + # + # # Insert multiple records + # Book.insert_all!([ + # { title: "Rework", author: "David" }, + # { title: "Eloquent Ruby", author: "Russ" } + # ]) + # + # # Raises ActiveRecord::RecordNotUnique because "Eloquent Ruby" + # # does not have a unique id. + # Book.insert_all!([ + # { id: 1, title: "Rework", author: "David" }, + # { id: 1, title: "Eloquent Ruby", author: "Russ" } + # ]) + def insert_all!(attributes, returning: nil) + InsertAll.new(self, attributes, on_duplicate: :raise, returning: returning).execute + end + + # Updates or inserts (upserts) a single record into the database in a + # single SQL INSERT statement. It does not instantiate any models nor does + # it trigger Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # See <tt>ActiveRecord::Persistence#upsert_all</tt> for documentation. + def upsert(attributes, returning: nil, unique_by: nil) + upsert_all([ attributes ], returning: returning, unique_by: unique_by) + end + + # Updates or inserts (upserts) multiple records into the database in a + # single SQL INSERT statement. It does not instantiate any models nor does + # it trigger Active Record callbacks or validations. Though passed values + # go through Active Record's type casting and serialization. + # + # The +attributes+ parameter is an Array of Hashes. Every Hash determines + # the attributes for a single row and must have the same keys. + # + # Returns an <tt>ActiveRecord::Result</tt> with its contents based on + # <tt>:returning</tt> (see below). + # + # ==== Options + # + # [:returning] + # (PostgreSQL only) An array of attributes to return for all successfully + # inserted records, which by default is the primary key. + # Pass <tt>returning: %w[ id name ]</tt> for both id and name + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL + # clause entirely. + # + # [:unique_by] + # (PostgreSQL and SQLite only) By default rows are considered to be unique + # by every unique index on the table. Any duplicate rows are skipped. + # + # To skip rows according to just one unique index pass <tt>:unique_by</tt>. + # + # Consider a Book model where no duplicate ISBNs make sense, but if any + # row has an existing id, or is not unique by another unique index, + # <tt>ActiveRecord::RecordNotUnique</tt> is raised. + # + # Unique indexes can be identified by columns or name: + # + # unique_by: :isbn + # unique_by: %i[ author_id name ] + # unique_by: :index_books_on_isbn + # + # Because it relies on the index information from the database + # <tt>:unique_by</tt> is recommended to be paired with + # Active Record's schema_cache. + # + # ==== Examples + # + # # Inserts multiple records, performing an upsert when records have duplicate ISBNs. + # # Here "Eloquent Ruby" overwrites "Rework" because its ISBN is duplicate. + # + # Book.upsert_all([ + # { title: "Rework", author: "David", isbn: "1" }, + # { title: "Eloquent Ruby", author: "Russ", isbn: "1" } + # ], unique_by: :isbn) + # + # Book.find_by(isbn: "1").title # => "Eloquent Ruby" + def upsert_all(attributes, returning: nil, unique_by: nil) + InsertAll.new(self, attributes, on_duplicate: :update, returning: returning, unique_by: unique_by).execute + end + # Given an attributes hash, +instantiate+ returns a new instance of # the appropriate class. Accepts only keys as strings. # @@ -67,8 +255,7 @@ module ActiveRecord # how this "single-table" inheritance mapping is implemented. def instantiate(attributes, column_types = {}, &block) klass = discriminate_class_for_record(attributes) - attributes = klass.attributes_builder.build_from_database(attributes, column_types) - klass.allocate.init_with("attributes" => attributes, "new_record" => false, &block) + instantiate_instance_of(klass, attributes, column_types, &block) end # Updates an object (or multiple objects) and saves it to the database, if validations pass. @@ -143,7 +330,7 @@ module ActiveRecord end end - # Deletes the row with a primary key matching the +id+ argument, using a + # Deletes the row with a primary key matching the +id+ argument, using an # SQL +DELETE+ statement, and returns the number of rows deleted. Active # Record objects are not instantiated, so the object's callbacks are not # executed, including any <tt>:dependent</tt> association options. @@ -162,7 +349,7 @@ module ActiveRecord # # Delete multiple rows # Todo.delete([2,3,4]) def delete(id_or_array) - where(primary_key => id_or_array).delete_all + delete_by(primary_key => id_or_array) end def _insert_record(values) # :nodoc: @@ -178,7 +365,7 @@ module ActiveRecord end if values.empty? - im = arel_table.compile_insert(connection.empty_insert_statement_value) + im = arel_table.compile_insert(connection.empty_insert_statement_value(primary_key)) im.into arel_table else im = arel_table.compile_insert(_substitute_values(values)) @@ -208,6 +395,13 @@ module ActiveRecord end private + # Given a class, an attributes hash, +instantiate_instance_of+ returns a + # new instance of the class. Accepts only keys as strings. + def instantiate_instance_of(klass, attributes, column_types = {}, &block) + attributes = klass.attributes_builder.build_from_database(attributes, column_types) + klass.allocate.init_with_attributes(attributes, &block) + end + # Called by +instantiate+ to decide which class to use for a new # record instance. # @@ -373,8 +567,7 @@ module ActiveRecord became = klass.allocate became.send(:initialize) became.instance_variable_set("@attributes", @attributes) - became.instance_variable_set("@mutations_from_database", @mutations_from_database) if defined?(@mutations_from_database) - became.instance_variable_set("@changed_attributes", attributes_changed_by_setter) + became.instance_variable_set("@mutations_from_database", @mutations_from_database ||= nil) became.instance_variable_set("@new_record", new_record?) became.instance_variable_set("@destroyed", destroyed?) became.errors.copy!(errors) @@ -430,7 +623,7 @@ module ActiveRecord end alias update_attributes update - deprecate :update_attributes + deprecate update_attributes: "please, use update instead" # Updates its receiver just like #update but calls #save! instead # of +save+, so an exception is raised if the record is invalid and saving will fail. @@ -444,7 +637,7 @@ module ActiveRecord end alias update_attributes! update! - deprecate :update_attributes! + deprecate update_attributes!: "please, use update! instead" # Equivalent to <code>update_columns(name => value)</code>. def update_column(name, value) @@ -475,15 +668,16 @@ module ActiveRecord verify_readonly_attribute(key.to_s) end + id_in_database = self.id_in_database + attributes.each do |k, v| + write_attribute_without_type_cast(k, v) + end + affected_rows = self.class._update_record( attributes, self.class.primary_key => id_in_database ) - attributes.each do |k, v| - write_attribute_without_type_cast(k, v) - end - affected_rows == 1 end @@ -700,17 +894,16 @@ module ActiveRecord ) end - def create_or_update(*args, &block) + def create_or_update(**, &block) _raise_readonly_record_error if readonly? return false if destroyed? - result = new_record? ? _create_record(&block) : _update_record(*args, &block) + result = new_record? ? _create_record(&block) : _update_record(&block) result != false end # Updates the associated record with values matching those of the instance attributes. # Returns the number of affected rows. def _update_record(attribute_names = self.attribute_names) - attribute_names &= self.class.column_names attribute_names = attributes_for_update(attribute_names) if attribute_names.empty? @@ -729,10 +922,12 @@ module ActiveRecord # Creates a record with values matching those of the instance attributes # and returns its id. def _create_record(attribute_names = self.attribute_names) - attribute_names &= self.class.column_names - attributes_values = attributes_with_values_for_create(attribute_names) + attribute_names = attributes_for_create(attribute_names) + + new_id = self.class._insert_record( + attributes_with_values(attribute_names) + ) - new_id = self.class._insert_record(attributes_values) self.id ||= new_id if self.class.primary_key @new_record = false @@ -753,6 +948,8 @@ module ActiveRecord @_association_destroy_exception = nil end + # The name of the method used to touch a +belongs_to+ association when the + # +:touch+ option is used. def belongs_to_touch_method :touch end diff --git a/activerecord/lib/active_record/query_cache.rb b/activerecord/lib/active_record/query_cache.rb index 28194c7c46..43a21e629e 100644 --- a/activerecord/lib/active_record/query_cache.rb +++ b/activerecord/lib/active_record/query_cache.rb @@ -26,15 +26,22 @@ module ActiveRecord end def self.run - ActiveRecord::Base.connection_handler.connection_pool_list. - reject { |p| p.query_cache_enabled }.each { |p| p.enable_query_cache! } + pools = [] + + ActiveRecord::Base.connection_handlers.each do |key, handler| + pools << handler.connection_pool_list.reject { |p| p.query_cache_enabled }.each { |p| p.enable_query_cache! } + end + + pools.flatten end def self.complete(pools) pools.each { |pool| pool.disable_query_cache! } - ActiveRecord::Base.connection_handler.connection_pool_list.each do |pool| - pool.release_connection if pool.active_connection? && !pool.connection.transaction_open? + ActiveRecord::Base.connection_handlers.each do |_, handler| + handler.connection_pool_list.each do |pool| + pool.release_connection if pool.active_connection? && !pool.connection.transaction_open? + end end end diff --git a/activerecord/lib/active_record/querying.rb b/activerecord/lib/active_record/querying.rb index d33d36ac02..08cfc3fe5f 100644 --- a/activerecord/lib/active_record/querying.rb +++ b/activerecord/lib/active_record/querying.rb @@ -2,31 +2,36 @@ module ActiveRecord module Querying - delegate :find, :take, :take!, :first, :first!, :last, :last!, :exists?, :any?, :many?, :none?, :one?, to: :all - delegate :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!, :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!, to: :all - delegate :first_or_create, :first_or_create!, :first_or_initialize, to: :all - delegate :find_or_create_by, :find_or_create_by!, :create_or_find_by, :create_or_find_by!, :find_or_initialize_by, to: :all - delegate :find_by, :find_by!, to: :all - delegate :destroy_all, :delete_all, :update_all, to: :all - delegate :find_each, :find_in_batches, :in_batches, to: :all - delegate :select, :group, :order, :except, :reorder, :limit, :offset, :joins, :left_joins, :left_outer_joins, :or, - :where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly, :extending, - :having, :create_with, :distinct, :references, :none, :unscope, :merge, to: :all - delegate :count, :average, :minimum, :maximum, :sum, :calculate, to: :all - delegate :pluck, :pick, :ids, to: :all + QUERYING_METHODS = [ + :find, :find_by, :find_by!, :take, :take!, :first, :first!, :last, :last!, + :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!, + :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!, + :exists?, :any?, :many?, :none?, :one?, + :first_or_create, :first_or_create!, :first_or_initialize, + :find_or_create_by, :find_or_create_by!, :find_or_initialize_by, + :create_or_find_by, :create_or_find_by!, + :destroy_all, :delete_all, :update_all, :touch_all, :destroy_by, :delete_by, + :find_each, :find_in_batches, :in_batches, + :select, :reselect, :order, :reorder, :group, :limit, :offset, :joins, :left_joins, :left_outer_joins, + :where, :rewhere, :preload, :extract_associated, :eager_load, :includes, :from, :lock, :readonly, :extending, :or, + :having, :create_with, :distinct, :references, :none, :unscope, :optimizer_hints, :merge, :except, :only, + :count, :average, :minimum, :maximum, :sum, :calculate, :annotate, + :pluck, :pick, :ids + ].freeze # :nodoc: + delegate(*QUERYING_METHODS, to: :all) # Executes a custom SQL query against your database and returns all the results. The results will - # be returned as an array with columns requested encapsulated as attributes of the model you call - # this method from. If you call <tt>Product.find_by_sql</tt> then the results will be returned in + # be returned as an array, with the requested columns encapsulated as attributes of the model you call + # this method from. For example, if you call <tt>Product.find_by_sql</tt>, then the results will be returned in # a +Product+ object with the attributes you specified in the SQL query. # - # If you call a complicated SQL query which spans multiple tables the columns specified by the + # If you call a complicated SQL query which spans multiple tables, the columns specified by the # SELECT will be attributes of the model, whether or not they are columns of the corresponding # table. # - # The +sql+ parameter is a full SQL query as a string. It will be called as is, there will be - # no database agnostic conversions performed. This should be a last resort because using, for example, - # MySQL specific terms will lock you to using that particular database engine or require you to + # The +sql+ parameter is a full SQL query as a string. It will be called as is; there will be + # no database agnostic conversions performed. This should be a last resort because using + # database-specific terms will lock you into using that particular database engine, or require you to # change your call if you switch engines. # # # A simple SQL query spanning multiple tables @@ -40,7 +45,7 @@ module ActiveRecord def find_by_sql(sql, binds = [], preparable: nil, &block) result_set = connection.select_all(sanitize_sql(sql), "#{name} Load", binds, preparable: preparable) column_types = result_set.column_types.dup - columns_hash.each_key { |k| column_types.delete k } + attribute_types.each_key { |k| column_types.delete k } message_bus = ActiveSupport::Notifications.instrumenter payload = { @@ -49,13 +54,20 @@ module ActiveRecord } message_bus.instrument("instantiation.active_record", payload) do - result_set.map { |record| instantiate(record, column_types, &block) } + if result_set.includes_column?(inheritance_column) + result_set.map { |record| instantiate(record, column_types, &block) } + else + # Instantiate a homogeneous set + result_set.map { |record| instantiate_instance_of(self, record, column_types, &block) } + end end end # Returns the result of an SQL statement that should only include a COUNT(*) in the SELECT part. # The use of this method should be restricted to complicated SQL queries that can't be executed - # using the ActiveRecord::Calculations class methods. Look into those before using this. + # using the ActiveRecord::Calculations class methods. Look into those before using this method, + # as it could lock you into a specific database engine or require a code change to switch + # database engines. # # Product.count_by_sql "SELECT COUNT(*) FROM sales s, customers c WHERE s.customer_id = c.id" # # => 12 diff --git a/activerecord/lib/active_record/railtie.rb b/activerecord/lib/active_record/railtie.rb index 6ab80a654d..a1d7c893bf 100644 --- a/activerecord/lib/active_record/railtie.rb +++ b/activerecord/lib/active_record/railtie.rb @@ -77,6 +77,10 @@ module ActiveRecord ActiveSupport.on_load(:active_record) { self.logger ||= ::Rails.logger } end + initializer "active_record.backtrace_cleaner" do + ActiveSupport.on_load(:active_record) { LogSubscriber.backtrace_cleaner = ::Rails.backtrace_cleaner } + end + initializer "active_record.migration_error" do if config.active_record.delete(:migration_error) == :page_load config.app_middleware.insert_after ::ActionDispatch::Callbacks, @@ -84,6 +88,39 @@ module ActiveRecord end end + initializer "active_record.database_selector" do + if options = config.active_record.delete(:database_selector) + resolver = config.active_record.delete(:database_resolver) + operations = config.active_record.delete(:database_resolver_context) + config.app_middleware.use ActiveRecord::Middleware::DatabaseSelector, resolver, operations, options + end + end + + initializer "Check for cache versioning support" do + config.after_initialize do |app| + ActiveSupport.on_load(:active_record) do + if app.config.active_record.cache_versioning && Rails.cache + unless Rails.cache.class.try(:supports_cache_versioning?) + raise <<-end_error + +You're using a cache store that doesn't support native cache versioning. +Your best option is to upgrade to a newer version of #{Rails.cache.class} +that supports cache versioning (#{Rails.cache.class}.supports_cache_versioning? #=> true). + +Next best, switch to a different cache store that does support cache versioning: +https://guides.rubyonrails.org/caching_with_rails.html#cache-stores. + +To keep using the current cache store, you can turn off cache versioning entirely: + + config.active_record.cache_versioning = false + +end_error + end + end + end + end + end + initializer "active_record.check_schema_cache_dump" do if config.active_record.delete(:use_schema_cache_dump) config.after_initialize do |app| @@ -108,6 +145,26 @@ module ActiveRecord end end + initializer "active_record.define_attribute_methods" do |app| + config.after_initialize do + ActiveSupport.on_load(:active_record) do + if app.config.eager_load + descendants.each do |model| + # SchemaMigration and InternalMetadata both override `table_exists?` + # to bypass the schema cache, so skip them to avoid the extra queries. + next if model._internal? + + # If there's no connection yet, or the schema cache doesn't have the columns + # hash for the model cached, `define_attribute_methods` would trigger a query. + next unless model.connected? && model.connection.schema_cache.columns_hash?(model.table_name) + + model.define_attribute_methods + end + end + end + end + end + initializer "active_record.warn_on_records_fetched_greater_than" do if config.active_record.warn_on_records_fetched_greater_than ActiveSupport.on_load(:active_record) do @@ -118,8 +175,18 @@ module ActiveRecord initializer "active_record.set_configs" do |app| ActiveSupport.on_load(:active_record) do - configs = app.config.active_record.dup + configs = app.config.active_record + + represent_boolean_as_integer = configs.sqlite3.delete(:represent_boolean_as_integer) + + unless represent_boolean_as_integer.nil? + ActiveSupport.on_load(:active_record_sqlite3adapter) do + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = represent_boolean_as_integer + end + end + configs.delete(:sqlite3) + configs.each do |k, v| send "#{k}=", v end @@ -130,22 +197,9 @@ module ActiveRecord # and then establishes the connection. initializer "active_record.initialize_database" do ActiveSupport.on_load(:active_record) do + self.connection_handlers = { writing_role => ActiveRecord::Base.default_connection_handler } self.configurations = Rails.application.config.database_configuration - - begin - establish_connection - rescue ActiveRecord::NoDatabaseError - warn <<-end_warning -Oops - You have a database configured, but it doesn't exist yet! - -Here's how to get started: - - 1. Configure your database in config/database.yml. - 2. Run `bin/rails db:create` to create the database. - 3. Run `bin/rails db:setup` to load your database schema. -end_warning - raise - end + establish_connection end end @@ -176,9 +230,7 @@ end_warning end initializer "active_record.set_executor_hooks" do - ActiveSupport.on_load(:active_record) do - ActiveRecord::QueryCache.install_executor_hooks - end + ActiveRecord::QueryCache.install_executor_hooks end initializer "active_record.add_watchable_files" do |app| @@ -203,32 +255,9 @@ end_warning end end - initializer "active_record.check_represent_sqlite3_boolean_as_integer" do - config.after_initialize do - ActiveSupport.on_load(:active_record_sqlite3adapter) do - represent_boolean_as_integer = Rails.application.config.active_record.sqlite3.delete(:represent_boolean_as_integer) - unless represent_boolean_as_integer.nil? - ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = represent_boolean_as_integer - end - - unless ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer - ActiveSupport::Deprecation.warn <<-MSG -Leaving `ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer` -set to false is deprecated. SQLite databases have used 't' and 'f' to serialize -boolean values and must have old data converted to 1 and 0 (its native boolean -serialization) before setting this flag to true. Conversion can be accomplished -by setting up a rake task which runs - - ExampleModel.where("boolean_column = 't'").update_all(boolean_column: 1) - ExampleModel.where("boolean_column = 'f'").update_all(boolean_column: 0) - -for all models and all boolean columns, after which the flag must be set to -true by adding the following to your application.rb file: - - Rails.application.config.active_record.sqlite3.represent_boolean_as_integer = true -MSG - end - end + initializer "active_record.set_filter_attributes" do + ActiveSupport.on_load(:active_record) do + self.filter_attributes += Rails.application.config.filter_parameters end end end diff --git a/activerecord/lib/active_record/railties/collection_cache_association_loading.rb b/activerecord/lib/active_record/railties/collection_cache_association_loading.rb index b5129e4239..d57680aaaa 100644 --- a/activerecord/lib/active_record/railties/collection_cache_association_loading.rb +++ b/activerecord/lib/active_record/railties/collection_cache_association_loading.rb @@ -3,7 +3,7 @@ module ActiveRecord module Railties # :nodoc: module CollectionCacheAssociationLoading #:nodoc: - def setup(context, options, block) + def setup(context, options, as, block) @relation = relation_from_options(options) super @@ -20,12 +20,12 @@ module ActiveRecord end end - def collection_without_template + def collection_without_template(*) @relation.preload_associations(@collection) if @relation super end - def collection_with_template + def collection_with_template(*) @relation.preload_associations(@collection) if @relation super end diff --git a/activerecord/lib/active_record/railties/databases.rake b/activerecord/lib/active_record/railties/databases.rake index 24449e8df3..447def8d77 100644 --- a/activerecord/lib/active_record/railties/databases.rake +++ b/activerecord/lib/active_record/railties/databases.rake @@ -26,7 +26,7 @@ db_namespace = namespace :db do ActiveRecord::Tasks::DatabaseTasks.for_each do |spec_name| desc "Create #{spec_name} database for current environment" task spec_name => :load_config do - db_config = ActiveRecord::DatabaseConfigurations.config_for_env_and_spec(Rails.env, spec_name) + db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, spec_name: spec_name) ActiveRecord::Tasks::DatabaseTasks.create(db_config.config) end end @@ -45,7 +45,7 @@ db_namespace = namespace :db do ActiveRecord::Tasks::DatabaseTasks.for_each do |spec_name| desc "Drop #{spec_name} database for current environment" task spec_name => [:load_config, :check_protected_environments] do - db_config = ActiveRecord::DatabaseConfigurations.config_for_env_and_spec(Rails.env, spec_name) + db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, spec_name: spec_name) ActiveRecord::Tasks::DatabaseTasks.drop(db_config.config) end end @@ -66,6 +66,11 @@ db_namespace = namespace :db do end end + # desc "Truncates tables of each database for current environment" + task truncate_all: [:load_config, :check_protected_environments] do + ActiveRecord::Tasks::DatabaseTasks.truncate_all + end + # desc "Empty the database from DATABASE_URL or config/database.yml for the current RAILS_ENV (use db:purge:all to purge all databases in the config). Without RAILS_ENV it defaults to purging the development and test databases." task purge: [:load_config, :check_protected_environments] do ActiveRecord::Tasks::DatabaseTasks.purge_current @@ -73,8 +78,8 @@ db_namespace = namespace :db do desc "Migrate the database (options: VERSION=x, VERBOSE=false, SCOPE=blog)." task migrate: :load_config do - ActiveRecord::DatabaseConfigurations.configs_for(Rails.env) do |spec_name, config| - ActiveRecord::Base.establish_connection(config) + ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.establish_connection(db_config.config) ActiveRecord::Tasks::DatabaseTasks.migrate end db_namespace["_dump"].invoke @@ -99,7 +104,7 @@ db_namespace = namespace :db do ActiveRecord::Tasks::DatabaseTasks.for_each do |spec_name| desc "Migrate #{spec_name} database for current environment" task spec_name => :load_config do - db_config = ActiveRecord::DatabaseConfigurations.config_for_env_and_spec(Rails.env, spec_name) + db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, spec_name: spec_name) ActiveRecord::Base.establish_connection(db_config.config) ActiveRecord::Tasks::DatabaseTasks.migrate end @@ -149,18 +154,21 @@ db_namespace = namespace :db do desc "Display status of migrations" task status: :load_config do - unless ActiveRecord::SchemaMigration.table_exists? - abort "Schema migrations table does not exist yet." + ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.establish_connection(db_config.config) + ActiveRecord::Tasks::DatabaseTasks.migrate_status end + end - # output - puts "\ndatabase: #{ActiveRecord::Base.connection_config[:database]}\n\n" - puts "#{'Status'.center(8)} #{'Migration ID'.ljust(14)} Migration Name" - puts "-" * 50 - ActiveRecord::Base.connection.migration_context.migrations_status.each do |status, version, name| - puts "#{status.center(8)} #{version.ljust(14)} #{name}" + namespace :status do + ActiveRecord::Tasks::DatabaseTasks.for_each do |spec_name| + desc "Display status of migrations for #{spec_name} database" + task spec_name => :load_config do + db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, spec_name: spec_name) + ActiveRecord::Base.establish_connection(db_config.config) + ActiveRecord::Tasks::DatabaseTasks.migrate_status + end end - puts end end @@ -188,11 +196,9 @@ db_namespace = namespace :db do # desc "Retrieves the collation for the current environment's database" task collation: :load_config do - begin - puts ActiveRecord::Tasks::DatabaseTasks.collation_current - rescue NoMethodError - $stderr.puts "Sorry, your database adapter is not supported yet. Feel free to submit a patch." - end + puts ActiveRecord::Tasks::DatabaseTasks.collation_current + rescue NoMethodError + $stderr.puts "Sorry, your database adapter is not supported yet. Feel free to submit a patch." end desc "Retrieves the current schema version number" @@ -216,12 +222,27 @@ db_namespace = namespace :db do desc "Creates the database, loads the schema, and initializes with the seed data (use db:reset to also drop the database first)" task setup: ["db:schema:load_if_ruby", "db:structure:load_if_sql", :seed] + desc "Runs setup if database does not exist, or runs migrations if it does" + task prepare: :load_config do + ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.establish_connection(db_config.config) + db_namespace["migrate"].invoke + rescue ActiveRecord::NoDatabaseError + db_namespace["setup"].invoke + end + end + desc "Loads the seed data from db/seeds.rb" - task :seed do + task seed: :load_config do db_namespace["abort_if_pending_migrations"].invoke ActiveRecord::Tasks::DatabaseTasks.load_seed end + namespace :seed do + desc "Truncates tables of each database for current environment and loads the seeds" + task replant: [:load_config, :truncate_all, :seed] + end + 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: :load_config do @@ -274,11 +295,10 @@ db_namespace = namespace :db do desc "Creates a db/schema.rb file that is portable against any DB supported by Active Record" task dump: :load_config do require "active_record/schema_dumper" - - ActiveRecord::DatabaseConfigurations.configs_for(Rails.env) do |spec_name, config| - filename = ActiveRecord::Tasks::DatabaseTasks.dump_filename(spec_name, :ruby) + ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + filename = ActiveRecord::Tasks::DatabaseTasks.dump_filename(db_config.spec_name, :ruby) File.open(filename, "w:utf-8") do |file| - ActiveRecord::Base.establish_connection(config) + ActiveRecord::Base.establish_connection(db_config.config) ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, file) end end @@ -298,15 +318,22 @@ db_namespace = namespace :db do namespace :cache do desc "Creates a db/schema_cache.yml file." 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) + ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.establish_connection(db_config.config) + filename = ActiveRecord::Tasks::DatabaseTasks.cache_dump_filename(db_config.spec_name) + ActiveRecord::Tasks::DatabaseTasks.dump_schema_cache( + ActiveRecord::Base.connection, + filename, + ) + end end desc "Clears a db/schema_cache.yml file." task clear: :load_config do - filename = File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, "schema_cache.yml") - rm_f filename, verbose: false + ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + filename = ActiveRecord::Tasks::DatabaseTasks.cache_dump_filename(db_config.spec_name) + rm_f filename, verbose: false + end end end end @@ -314,11 +341,10 @@ 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: :load_config do - ActiveRecord::DatabaseConfigurations.configs_for(Rails.env) do |spec_name, config| - ActiveRecord::Base.establish_connection(config) - filename = ActiveRecord::Tasks::DatabaseTasks.dump_filename(spec_name, :sql) - ActiveRecord::Tasks::DatabaseTasks.structure_dump(config, filename) - + ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| + ActiveRecord::Base.establish_connection(db_config.config) + filename = ActiveRecord::Tasks::DatabaseTasks.dump_filename(db_config.spec_name, :sql) + ActiveRecord::Tasks::DatabaseTasks.structure_dump(db_config.config, filename) if ActiveRecord::SchemaMigration.table_exists? File.open(filename, "a") do |f| f.puts ActiveRecord::Base.connection.dump_schema_information @@ -353,25 +379,31 @@ db_namespace = namespace :db do # desc "Recreate the test database from an existent schema.rb file" task load_schema: %w(db:test:purge) do - begin - should_reconnect = ActiveRecord::Base.connection_pool.active_connection? - ActiveRecord::Schema.verbose = false - ActiveRecord::Tasks::DatabaseTasks.load_schema ActiveRecord::Base.configurations["test"], :ruby, ENV["SCHEMA"], "test" - ensure - if should_reconnect - ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations[ActiveRecord::Tasks::DatabaseTasks.env]) - end + should_reconnect = ActiveRecord::Base.connection_pool.active_connection? + ActiveRecord::Schema.verbose = false + ActiveRecord::Base.configurations.configs_for(env_name: "test").each do |db_config| + filename = ActiveRecord::Tasks::DatabaseTasks.dump_filename(db_config.spec_name, :ruby) + ActiveRecord::Tasks::DatabaseTasks.load_schema(db_config.config, :ruby, filename, "test") + end + ensure + if should_reconnect + ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations.default_hash(ActiveRecord::Tasks::DatabaseTasks.env)) end end # desc "Recreate the test database from an existent structure.sql file" task load_structure: %w(db:test:purge) do - ActiveRecord::Tasks::DatabaseTasks.load_schema ActiveRecord::Base.configurations["test"], :sql, ENV["SCHEMA"], "test" + ActiveRecord::Base.configurations.configs_for(env_name: "test").each do |db_config| + filename = ActiveRecord::Tasks::DatabaseTasks.dump_filename(db_config.spec_name, :sql) + ActiveRecord::Tasks::DatabaseTasks.load_schema(db_config.config, :sql, filename, "test") + end end # desc "Empty the test database" task purge: %w(load_config check_protected_environments) do - ActiveRecord::Tasks::DatabaseTasks.purge ActiveRecord::Base.configurations["test"] + ActiveRecord::Base.configurations.configs_for(env_name: "test").each do |db_config| + ActiveRecord::Tasks::DatabaseTasks.purge(db_config.config) + end end # desc 'Load the test schema' @@ -395,6 +427,10 @@ namespace :railties do if railtie.respond_to?(:paths) && (path = railtie.paths["db/migrate"].first) railties[railtie.railtie_name] = path end + + unless ENV["MIGRATIONS_PATH"].blank? + railties[railtie.railtie_name] = railtie.root + ENV["MIGRATIONS_PATH"] + end end on_skip = Proc.new do |name, migration| diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 2f43d005f3..1312bf6f91 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -13,33 +13,37 @@ module ActiveRecord class_attribute :aggregate_reflections, instance_writer: false, default: {} end - def self.create(macro, name, scope, options, ar) - klass = \ - case macro - when :composed_of - AggregateReflection - when :has_many - HasManyReflection - when :has_one - HasOneReflection - when :belongs_to - BelongsToReflection - else - raise "Unsupported Macro: #{macro}" - end + class << self + def create(macro, name, scope, options, ar) + reflection = reflection_class_for(macro).new(name, scope, options, ar) + options[:through] ? ThroughReflection.new(reflection) : reflection + end - reflection = klass.new(name, scope, options, ar) - options[:through] ? ThroughReflection.new(reflection) : reflection - end + def add_reflection(ar, name, reflection) + ar.clear_reflections_cache + name = -name.to_s + ar._reflections = ar._reflections.except(name).merge!(name => reflection) + end - def self.add_reflection(ar, name, reflection) - ar.clear_reflections_cache - name = name.to_s - ar._reflections = ar._reflections.except(name).merge!(name => reflection) - end + def add_aggregate_reflection(ar, name, reflection) + ar.aggregate_reflections = ar.aggregate_reflections.merge(-name.to_s => reflection) + end - def self.add_aggregate_reflection(ar, name, reflection) - ar.aggregate_reflections = ar.aggregate_reflections.merge(name.to_s => reflection) + private + def reflection_class_for(macro) + case macro + when :composed_of + AggregateReflection + when :has_many + HasManyReflection + when :has_one + HasOneReflection + when :belongs_to + BelongsToReflection + else + raise "Unsupported Macro: #{macro}" + end + end end # \Reflection enables the ability to examine the associations and aggregations of @@ -174,28 +178,24 @@ module ActiveRecord scope ? [scope] : [] end - def build_join_constraint(table, foreign_table) - key = join_keys.key - foreign_key = join_keys.foreign_key - - constraint = table[key].eq(foreign_table[foreign_key]) - - if klass.finder_needs_type_condition? - table.create_and([constraint, klass.send(:type_condition, table)]) - else - constraint - end - end - - def join_scope(table, foreign_klass) + def join_scope(table, foreign_table, foreign_klass) predicate_builder = predicate_builder(table) scope_chain_items = join_scopes(table, predicate_builder) klass_scope = klass_join_scope(table, predicate_builder) + key = join_keys.key + foreign_key = join_keys.foreign_key + + klass_scope.where!(table[key].eq(foreign_table[foreign_key])) + if type klass_scope.where!(type => foreign_klass.polymorphic_name) end + if klass.finder_needs_type_condition? + klass_scope.where!(klass.send(:type_condition, table)) + end + scope_chain_items.inject(klass_scope, &:merge!) end @@ -417,7 +417,7 @@ module ActiveRecord class AssociationReflection < MacroReflection #:nodoc: def compute_class(name) if polymorphic? - raise ArgumentError, "Polymorphic association does not support to compute class." + raise ArgumentError, "Polymorphic associations do not support computing the class." end active_record.send(:compute_type, name) end diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index 3c66d2f8be..cd62b0b881 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -5,7 +5,7 @@ module ActiveRecord class Relation MULTI_VALUE_METHODS = [:includes, :eager_load, :preload, :select, :group, :order, :joins, :left_outer_joins, :references, - :extending, :unscope] + :extending, :unscope, :optimizer_hints, :annotate] SINGLE_VALUE_METHODS = [:limit, :offset, :lock, :readonly, :reordering, :reverse_order, :distinct, :create_with, :skip_query_cache] @@ -43,6 +43,17 @@ module ActiveRecord klass.arel_attribute(name, table) end + def bind_attribute(name, value) # :nodoc: + if reflection = klass._reflect_on_association(name) + name = reflection.foreign_key + value = value.read_attribute(reflection.klass.primary_key) unless value.nil? + end + + attr = arel_attribute(name) + bind = predicate_builder.build_bind_attribute(attr.name, value) + yield attr, bind + end + # Initializes new record from relation while maintaining the current # scope. # @@ -56,7 +67,8 @@ module ActiveRecord # user = users.new { |user| user.name = 'Oscar' } # user.name # => Oscar def new(attributes = nil, &block) - scoping { klass.new(scope_for_create(attributes), &block) } + block = _deprecated_scope_block("new", &block) + scoping { klass.new(attributes, &block) } end alias build new @@ -84,7 +96,8 @@ module ActiveRecord if attributes.is_a?(Array) attributes.collect { |attr| create(attr, &block) } else - scoping { klass.create(scope_for_create(attributes), &block) } + block = _deprecated_scope_block("create", &block) + scoping { klass.create(attributes, &block) } end end @@ -98,7 +111,8 @@ module ActiveRecord if attributes.is_a?(Array) attributes.collect { |attr| create!(attr, &block) } else - scoping { klass.create!(scope_for_create(attributes), &block) } + block = _deprecated_scope_block("create!", &block) + scoping { klass.create!(attributes, &block) } end end @@ -165,7 +179,7 @@ module ActiveRecord # Attempts to create a record with the given attributes in a table that has a unique constraint # on one or several of its columns. If a row already exists with one or several of these # unique constraints, the exception such an insertion would normally raise is caught, - # and the existing record with those attributes is found using #find_by. + # and the existing record with those attributes is found using #find_by!. # # This is similar to #find_or_create_by, but avoids the problem of stale reads between the SELECT # and the INSERT, as that method needs to first query the table, then attempt to insert a row @@ -175,7 +189,7 @@ module ActiveRecord # # * The underlying table must have the relevant columns defined with unique constraints. # * A unique constraint violation may be triggered by only one, or at least less than all, - # of the given attributes. This means that the subsequent #find_by may fail to find a + # of the given attributes. This means that the subsequent #find_by! may fail to find a # matching record, which will then raise an <tt>ActiveRecord::RecordNotFound</tt> exception, # rather than a record with the given attributes. # * While we avoid the race condition between SELECT -> INSERT from #find_or_create_by, @@ -183,6 +197,10 @@ module ActiveRecord # if a DELETE between those two statements is run by another client. But for most applications, # that's a significantly less likely condition to hit. # * It relies on exception handling to handle control flow, which may be marginally slower. + # * The primary key may auto-increment on each create, even if it fails. This can accelerate + # the problem of running out of integers, if the underlying table is still stuck on a primary + # key of type int (note: All Rails apps since 5.1+ have defaulted to bigint, which is not liable + # to this problem). # # This method will return a record if all given attributes are covered by unique constraints # (unless the INSERT -> DELETE -> SELECT race condition is triggered), but if creation was attempted @@ -217,7 +235,7 @@ module ActiveRecord # are needed by the next ones when eager loading is going on. # # Please see further details in the - # {Active Record Query Interface guide}[http://guides.rubyonrails.org/active_record_querying.html#running-explain]. + # {Active Record Query Interface guide}[https://guides.rubyonrails.org/active_record_querying.html#running-explain]. def explain exec_explain(collecting_queries_for_explain { exec_queries }) end @@ -299,6 +317,51 @@ module ActiveRecord @cache_keys[timestamp_column] ||= @klass.collection_cache_key(self, timestamp_column) end + def compute_cache_key(timestamp_column = :updated_at) # :nodoc: + query_signature = ActiveSupport::Digest.hexdigest(to_sql) + key = "#{klass.model_name.cache_key}/query-#{query_signature}" + + if loaded? || distinct_value + size = records.size + if size > 0 + timestamp = max_by(×tamp_column)._read_attribute(timestamp_column) + end + else + collection = eager_loading? ? apply_join_dependency : self + + column = connection.visitor.compile(arel_attribute(timestamp_column)) + select_values = "COUNT(*) AS #{connection.quote_column_name("size")}, MAX(%s) AS timestamp" + + if collection.has_limit_or_offset? + query = collection.select("#{column} AS collection_cache_key_timestamp") + subquery_alias = "subquery_for_cache_key" + subquery_column = "#{subquery_alias}.collection_cache_key_timestamp" + arel = query.build_subquery(subquery_alias, select_values % subquery_column) + else + query = collection.unscope(:order) + query.select_values = [select_values % column] + arel = query.arel + end + + result = connection.select_one(arel, nil) + + if result + column_type = klass.type_for_attribute(timestamp_column) + timestamp = column_type.deserialize(result["timestamp"]) + size = result["size"] + else + timestamp = nil + size = 0 + end + end + + if timestamp + "#{key}-#{size}-#{timestamp.utc.to_s(cache_timestamp_format)}" + else + "#{key}-#{size}" + end + end + # Scope all queries to the current scope. # # Comment.where(post_id: 1).scoping do @@ -309,15 +372,12 @@ module ActiveRecord # Please check unscoped if you want to remove all previous scopes (including # the default_scope) during the execution of a block. def scoping - previous, klass.current_scope = klass.current_scope(true), self - yield - ensure - klass.current_scope = previous + already_in_scope? ? yield : _scoping(self) { yield } end - def _exec_scope(*args, &block) # :nodoc: + def _exec_scope(name, *args, &block) # :nodoc: @delegate_to_klass = true - instance_exec(*args, &block) || self + _scoping(_deprecated_spawn(name)) { instance_exec(*args, &block) || self } ensure @delegate_to_klass = false end @@ -327,6 +387,8 @@ module ActiveRecord # trigger Active Record callbacks or validations. However, values passed to #update_all will still go through # Active Record's normal type casting and serialization. # + # Note: As Active Record callbacks are not triggered, this method will not automatically update +updated_at+/+updated_on+ columns. + # # ==== Parameters # # * +updates+ - A string, array, or hash representing the SET part of an SQL statement. @@ -353,20 +415,79 @@ module ActiveRecord end stmt = Arel::UpdateManager.new + stmt.table(arel.join_sources.empty? ? table : arel.source) + stmt.key = arel_attribute(primary_key) + stmt.take(arel.limit) + stmt.offset(arel.offset) + stmt.order(*arel.orders) + stmt.wheres = arel.constraints + + if updates.is_a?(Hash) + if klass.locking_enabled? && + !updates.key?(klass.locking_column) && + !updates.key?(klass.locking_column.to_sym) + attr = arel_attribute(klass.locking_column) + updates[attr.name] = _increment_attribute(attr) + end + stmt.set _substitute_values(updates) + else + stmt.set Arel.sql(klass.sanitize_sql_for_assignment(updates, table.name)) + end - stmt.set Arel.sql(@klass.sanitize_sql_for_assignment(updates)) - stmt.table(table) + @klass.connection.update stmt, "#{@klass} Update All" + end - if has_join_values? || offset_value - @klass.connection.join_to_update(stmt, arel, arel_attribute(primary_key)) + def update(id = :all, attributes) # :nodoc: + if id == :all + each { |record| record.update(attributes) } else - stmt.key = arel_attribute(primary_key) - stmt.take(arel.limit) - stmt.order(*arel.orders) - stmt.wheres = arel.constraints + klass.update(id, attributes) end + end - @klass.connection.update stmt, "#{@klass} Update All" + def update_counters(counters) # :nodoc: + touch = counters.delete(:touch) + + updates = {} + counters.each do |counter_name, value| + attr = arel_attribute(counter_name) + updates[attr.name] = _increment_attribute(attr, value) + end + + if touch + names = touch if touch != true + touch_updates = klass.touch_attributes_with_time(*names) + updates.merge!(touch_updates) unless touch_updates.empty? + end + + update_all updates + end + + # Touches all records in the current relation without instantiating records first with the +updated_at+/+updated_on+ attributes + # set to the current time or the time specified. + # This method can be passed attribute names and an optional time argument. + # If attribute names are passed, they are updated along with +updated_at+/+updated_on+ attributes. + # If no time argument is passed, the current time is used as default. + # + # === Examples + # + # # Touch all records + # Person.all.touch_all + # # => "UPDATE \"people\" SET \"updated_at\" = '2018-01-04 22:55:23.132670'" + # + # # Touch multiple records with a custom attribute + # Person.all.touch_all(:created_at) + # # => "UPDATE \"people\" SET \"updated_at\" = '2018-01-04 22:55:23.132670', \"created_at\" = '2018-01-04 22:55:23.132670'" + # + # # Touch multiple records with a specified time + # Person.all.touch_all(time: Time.new(2020, 5, 16, 0, 0, 0)) + # # => "UPDATE \"people\" SET \"updated_at\" = '2020-05-16 00:00:00'" + # + # # Touch records with scope + # Person.where(name: 'David').touch_all + # # => "UPDATE \"people\" SET \"updated_at\" = '2018-01-04 22:55:23.132670' WHERE \"people\".\"name\" = 'David'" + def touch_all(*names, time: nil) + update_all klass.touch_attributes_with_time(*names, time: time) end # Destroys the records by instantiating each @@ -422,13 +543,12 @@ module ActiveRecord end stmt = Arel::DeleteManager.new - stmt.from(table) - - if has_join_values? || has_limit_or_offset? - @klass.connection.join_to_delete(stmt, arel, arel_attribute(primary_key)) - else - stmt.wheres = arel.constraints - end + stmt.from(arel.join_sources.empty? ? table : arel.source) + stmt.key = arel_attribute(primary_key) + stmt.take(arel.limit) + stmt.offset(arel.offset) + stmt.order(*arel.orders) + stmt.wheres = arel.constraints affected = @klass.connection.delete(stmt, "#{@klass} Destroy") @@ -436,6 +556,32 @@ module ActiveRecord affected end + # Finds and destroys all records matching the specified conditions. + # This is short-hand for <tt>relation.where(condition).destroy_all</tt>. + # Returns the collection of objects that were destroyed. + # + # If no record is found, returns empty array. + # + # Person.destroy_by(id: 13) + # Person.destroy_by(name: 'Spartacus', rating: 4) + # Person.destroy_by("published_at < ?", 2.weeks.ago) + def destroy_by(*args) + where(*args).destroy_all + end + + # Finds and deletes all records matching the specified conditions. + # This is short-hand for <tt>relation.where(condition).delete_all</tt>. + # Returns the number of rows affected. + # + # If no record is found, returns <tt>0</tt> as zero rows were affected. + # + # Person.delete_by(id: 13) + # Person.delete_by(name: 'Spartacus', rating: 4) + # Person.delete_by("published_at < ?", 2.weeks.ago) + def delete_by(*args) + where(*args).delete_all + end + # Causes the records to be loaded from the database if they have not # been loaded already. You can use this if for some reason you need # to explicitly load some records before actually using them. The @@ -456,6 +602,7 @@ module ActiveRecord def reset @delegate_to_klass = false + @_deprecated_scope_source = nil @to_sql = @arel = @loaded = @should_eager_load = nil @records = [].freeze @offsets = {} @@ -468,17 +615,16 @@ module ActiveRecord # # => SELECT "users".* FROM "users" WHERE "users"."name" = 'Oscar' def to_sql @to_sql ||= begin - relation = self - - if eager_loading? - apply_join_dependency { |rel, _| relation = rel } - end - - conn = klass.connection - conn.unprepared_statement { - conn.to_sql(relation.arel) - } - end + if eager_loading? + apply_join_dependency do |relation, join_dependency| + relation = join_dependency.apply_column_aliases(relation) + relation.to_sql + end + else + conn = klass.connection + conn.unprepared_statement { conn.to_sql(arel) } + end + end end # Returns a hash of where conditions. @@ -489,10 +635,8 @@ module ActiveRecord where_clause.to_h(relation_table_name) end - def scope_for_create(attributes = nil) - scope = where_values_hash.merge!(create_with_value.stringify_keys) - scope.merge!(attributes) if attributes - scope + def scope_for_create + where_values_hash.merge!(create_with_value.stringify_keys) end # Returns true if relation needs eager loading. @@ -557,7 +701,7 @@ module ActiveRecord ActiveRecord::Associations::AliasTracker.create(connection, table.name, joins) end - def preload_associations(records) + def preload_associations(records) # :nodoc: preload = preload_values preload += includes_values unless eager_loading? preloader = nil @@ -567,17 +711,62 @@ module ActiveRecord end end + attr_reader :_deprecated_scope_source # :nodoc: + protected + attr_writer :_deprecated_scope_source # :nodoc: def load_records(records) @records = records.freeze @loaded = true end + def null_relation? # :nodoc: + is_a?(NullRelation) + end + private + def already_in_scope? + @delegate_to_klass && begin + scope = klass.current_scope(true) + scope && !scope._deprecated_scope_source + end + end + + def _deprecated_spawn(name) + spawn.tap { |scope| scope._deprecated_scope_source = name } + end + + def _deprecated_scope_block(name, &block) + -> record do + klass.current_scope = _deprecated_spawn(name) + yield record if block_given? + end + end + + def _scoping(scope) + previous, klass.current_scope = klass.current_scope(true), scope + yield + ensure + klass.current_scope = previous + end + + def _substitute_values(values) + values.map do |name, value| + attr = arel_attribute(name) + unless Arel.arel_node?(value) + type = klass.type_for_attribute(attr.name) + value = predicate_builder.build_bind_attribute(attr.name, type.cast(value)) + end + [attr, value] + end + end - def has_join_values? - joins_values.any? || left_outer_joins_values.any? + def _increment_attribute(attribute, value = 1) + bind = predicate_builder.build_bind_attribute(attribute.name, value.abs) + expr = table.coalesce(Arel::Nodes::UnqualifiedColumn.new(attribute), 0) + expr = value < 0 ? expr - bind : expr + bind + expr.expr end def exec_queries(&block) @@ -585,9 +774,10 @@ module ActiveRecord @records = if eager_loading? apply_join_dependency do |relation, join_dependency| - if ActiveRecord::NullRelation === relation + if relation.null_relation? [] else + relation = join_dependency.apply_column_aliases(relation) rows = connection.select_all(relation.arel, "SQL") join_dependency.instantiate(rows, &block) end.freeze diff --git a/activerecord/lib/active_record/relation/batches.rb b/activerecord/lib/active_record/relation/batches.rb index ec4bb06c57..9c579843b1 100644 --- a/activerecord/lib/active_record/relation/batches.rb +++ b/activerecord/lib/active_record/relation/batches.rb @@ -251,8 +251,9 @@ module ActiveRecord end end - bind = primary_key_bind(primary_key_offset) - batch_relation = relation.where(arel_attribute(primary_key).gt(bind)) + batch_relation = relation.where( + bind_attribute(primary_key, primary_key_offset) { |attr, bind| attr.gt(bind) } + ) end end @@ -265,15 +266,11 @@ module ActiveRecord end def apply_start_limit(relation, start) - relation.where(arel_attribute(primary_key).gteq(primary_key_bind(start))) + relation.where(bind_attribute(primary_key, start) { |attr, bind| attr.gteq(bind) }) end def apply_finish_limit(relation, finish) - relation.where(arel_attribute(primary_key).lteq(primary_key_bind(finish))) - end - - def primary_key_bind(value) - predicate_builder.build_bind_attribute(primary_key, value) + relation.where(bind_attribute(primary_key, finish) { |attr, bind| attr.lteq(bind) }) end def batch_order diff --git a/activerecord/lib/active_record/relation/calculations.rb b/activerecord/lib/active_record/relation/calculations.rb index f215c95f51..801e312658 100644 --- a/activerecord/lib/active_record/relation/calculations.rb +++ b/activerecord/lib/active_record/relation/calculations.rb @@ -41,15 +41,13 @@ module ActiveRecord def count(column_name = nil) if block_given? unless column_name.nil? - ActiveSupport::Deprecation.warn \ - "When `count' is called with a block, it ignores other arguments. " \ - "This behavior is now deprecated and will result in an ArgumentError in Rails 6.0." + raise ArgumentError, "Column name argument is not supported when a block is passed." end - return super() + super() + else + calculate(:count, column_name) end - - calculate(:count, column_name) end # Calculates the average value on a given column. Returns +nil+ if there's @@ -86,15 +84,13 @@ module ActiveRecord def sum(column_name = nil) if block_given? unless column_name.nil? - ActiveSupport::Deprecation.warn \ - "When `sum' is called with a block, it ignores other arguments. " \ - "This behavior is now deprecated and will result in an ArgumentError in Rails 6.0." + raise ArgumentError, "Column name argument is not supported when a block is passed." end - return super() + super() + else + calculate(:sum, column_name) end - - calculate(:sum, column_name) end # This calculates aggregate values in the given column. Methods for #count, #sum, #average, @@ -133,11 +129,12 @@ module ActiveRecord relation = apply_join_dependency if operation.to_s.downcase == "count" - relation.distinct! - # PostgreSQL: ORDER BY expressions must appear in SELECT list when using DISTINCT - if (column_name == :all || column_name.nil?) && select_values.empty? - relation.order_values = [] + unless distinct_value || distinct_select?(column_name || select_for_count) + relation.distinct! + relation.select_values = [ klass.primary_key || table[Arel.star] ] end + # PostgreSQL: ORDER BY expressions must appear in SELECT list when using DISTINCT + relation.order_values = [] end relation.calculate(operation, column_name) @@ -190,11 +187,9 @@ module ActiveRecord relation = apply_join_dependency relation.pluck(*column_names) else - enforce_raw_sql_whitelist(column_names) + klass.disallow_raw_sql!(column_names) relation = spawn - relation.select_values = column_names.map { |cn| - @klass.has_attribute?(cn) || @klass.attribute_alias?(cn) ? arel_attribute(cn) : cn - } + relation.select_values = column_names result = skip_query_cache_if_necessary { klass.connection.select_all(relation.arel, nil) } result.cast_values(klass.attribute_types) end @@ -227,7 +222,6 @@ module ActiveRecord end private - def has_include?(column_name) eager_loading? || (includes_values.present? && column_name && column_name != :all) end @@ -242,10 +236,12 @@ module ActiveRecord if operation == "count" column_name ||= select_for_count if column_name == :all - if distinct && (group_values.any? || select_values.empty? && order_values.empty?) + if !distinct + distinct = distinct_select?(select_for_count) if group_values.empty? + elsif group_values.any? || select_values.empty? && order_values.empty? column_name = primary_key end - elsif column_name =~ /\s*DISTINCT[\s(]+/i + elsif distinct_select?(column_name) distinct = nil end end @@ -257,6 +253,10 @@ module ActiveRecord end end + def distinct_select?(column_name) + column_name.is_a?(::String) && /\bDISTINCT[\s(]/i.match?(column_name) + end + def aggregate_column(column_name) return column_name if Arel::Expressions === column_name @@ -308,25 +308,22 @@ module ActiveRecord end def execute_grouped_calculation(operation, column_name, distinct) #:nodoc: - group_attrs = group_values + group_fields = group_values - if group_attrs.first.respond_to?(:to_sym) - association = @klass._reflect_on_association(group_attrs.first) - associated = group_attrs.size == 1 && association && association.belongs_to? # only count belongs_to associations - group_fields = Array(associated ? association.foreign_key : group_attrs) - else - group_fields = group_attrs + if group_fields.size == 1 && group_fields.first.respond_to?(:to_sym) + association = klass._reflect_on_association(group_fields.first) + associated = association && association.belongs_to? # only count belongs_to associations + group_fields = Array(association.foreign_key) if associated end group_fields = arel_columns(group_fields) - group_aliases = group_fields.map { |field| column_alias_for(field) } + group_aliases = group_fields.map { |field| + field = connection.visitor.compile(field) if Arel.arel_node?(field) + column_alias_for(field.to_s.downcase) + } group_columns = group_aliases.zip(group_fields) - if operation == "count" && column_name == :all - aggregate_alias = "count_all" - else - aggregate_alias = column_alias_for([operation, column_name].join(" ")) - end + aggregate_alias = column_alias_for("#{operation}_#{column_name.to_s.downcase}") select_values = [ operation_over_aggregate_column( @@ -345,7 +342,7 @@ module ActiveRecord } relation = except(:group).distinct!(false) - relation.group_values = group_fields + relation.group_values = group_aliases relation.select_values = select_values calculated_data = skip_query_cache_if_necessary { @klass.connection.select_all(relation.arel, nil) } @@ -371,25 +368,23 @@ module ActiveRecord end] end - # Converts the given keys to the value that the database adapter returns as + # Converts the given field to the value that the database adapter returns as # a usable column name: # # column_alias_for("users.id") # => "users_id" # column_alias_for("sum(id)") # => "sum_id" # column_alias_for("count(distinct users.id)") # => "count_distinct_users_id" # column_alias_for("count(*)") # => "count_all" - def column_alias_for(keys) - if keys.respond_to? :name - keys = "#{keys.relation.name}.#{keys.name}" - end + def column_alias_for(field) + return field if field.match?(/\A\w{,#{connection.table_alias_length}}\z/) - table_name = keys.to_s.downcase - table_name.gsub!(/\*/, "all") - table_name.gsub!(/\W+/, " ") - table_name.strip! - table_name.gsub!(/ +/, "_") + column_alias = +field + column_alias.gsub!(/\*/, "all") + column_alias.gsub!(/\W+/, " ") + column_alias.strip! + column_alias.gsub!(/ +/, "_") - @klass.connection.table_alias_for(table_name) + connection.table_alias_for(column_alias) end def type_for(field, &block) @@ -401,7 +396,7 @@ module ActiveRecord case operation when "count" then value.to_i when "sum" then type.deserialize(value || 0) - when "average" then value.respond_to?(:to_d) ? value.to_d : value + when "average" then value&.respond_to?(:to_d) ? value.to_d : value else type.deserialize(value) end end @@ -417,16 +412,17 @@ module ActiveRecord def build_count_subquery(relation, column_name, distinct) if column_name == :all + column_alias = Arel.star relation.select_values = [ Arel.sql(FinderMethods::ONE_AS_ONE) ] unless distinct else column_alias = Arel.sql("count_column") relation.select_values = [ aggregate_column(column_name).as(column_alias) ] end - subquery = relation.arel.as(Arel.sql("subquery_for_count")) - select_value = operation_over_aggregate_column(column_alias || Arel.star, "count", false) + subquery_alias = Arel.sql("subquery_for_count") + select_value = operation_over_aggregate_column(column_alias, "count", false) - Arel::SelectManager.new(subquery).project(select_value) + relation.build_subquery(subquery_alias, select_value) end end end diff --git a/activerecord/lib/active_record/relation/delegation.rb b/activerecord/lib/active_record/relation/delegation.rb index 488f71cdde..7a53a9d1c7 100644 --- a/activerecord/lib/active_record/relation/delegation.rb +++ b/activerecord/lib/active_record/relation/delegation.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "mutex_m" + module ActiveRecord module Delegation # :nodoc: module DelegateCache # :nodoc: @@ -17,7 +19,8 @@ module ActiveRecord delegate = Class.new(klass) { include ClassSpecificRelation } - mangled_name = klass.name.gsub("::".freeze, "_".freeze) + include_relation_methods(delegate) + mangled_name = klass.name.gsub("::", "_") const_set mangled_name, delegate private_constant mangled_name @@ -29,8 +32,46 @@ module ActiveRecord child_class.initialize_relation_delegate_cache super end + + def generate_relation_method(method) + generated_relation_methods.generate_method(method) + end + + protected + def include_relation_methods(delegate) + superclass.include_relation_methods(delegate) unless base_class? + delegate.include generated_relation_methods + end + + private + def generated_relation_methods + @generated_relation_methods ||= GeneratedRelationMethods.new + end end + class GeneratedRelationMethods < Module # :nodoc: + include Mutex_m + + def generate_method(method) + synchronize do + return if method_defined?(method) + + if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method) + module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{method}(*args, &block) + scoping { klass.#{method}(*args, &block) } + end + RUBY + else + define_method(method) do |*args, &block| + scoping { klass.public_send(method, *args, &block) } + end + end + end + end + end + private_constant :GeneratedRelationMethods + extend ActiveSupport::Concern # This module creates compiled delegation methods dynamically at runtime, which makes @@ -48,49 +89,18 @@ module ActiveRecord module ClassSpecificRelation # :nodoc: extend ActiveSupport::Concern - included do - @delegation_mutex = Mutex.new - end - module ClassMethods # :nodoc: def name superclass.name end - - def delegate_to_scoped_klass(method) - @delegation_mutex.synchronize do - return if method_defined?(method) - - if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method) - module_eval <<-RUBY, __FILE__, __LINE__ + 1 - def #{method}(*args, &block) - scoping { @klass.#{method}(*args, &block) } - end - RUBY - else - define_method method do |*args, &block| - scoping { @klass.public_send(method, *args, &block) } - end - end - end - end end private def method_missing(method, *args, &block) if @klass.respond_to?(method) - self.class.delegate_to_scoped_klass(method) + @klass.generate_relation_method(method) scoping { @klass.public_send(method, *args, &block) } - elsif @delegate_to_klass && @klass.respond_to?(method, true) - ActiveSupport::Deprecation.warn \ - "Delegating missing #{method} method to #{@klass}. " \ - "Accessibility of private/protected class methods in :scope is deprecated and will be removed in Rails 6.0." - @klass.send(method, *args, &block) - elsif arel.respond_to?(method) - ActiveSupport::Deprecation.warn \ - "Delegating #{method} to arel is deprecated and will be removed in Rails 6.0." - arel.public_send(method, *args, &block) else super end @@ -111,7 +121,7 @@ module ActiveRecord private def respond_to_missing?(method, _) - super || @klass.respond_to?(method) || arel.respond_to?(method) + super || @klass.respond_to?(method) end end end diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index f7613a187d..9450e4d3c5 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -7,8 +7,8 @@ module ActiveRecord ONE_AS_ONE = "1 AS one" # Find by id - This can either be a specific id (1), a list of ids (1, 5, 6), or an array of ids ([5, 6, 10]). - # If one or more records can not be found for the requested ids, then RecordNotFound will be raised. If the primary key - # is an integer, find by id coerces its arguments using +to_i+. + # If one or more records cannot be found for the requested ids, then ActiveRecord::RecordNotFound will be raised. + # If the primary key is an integer, find by id coerces its arguments by using +to_i+. # # Person.find(1) # returns the object for ID = 1 # Person.find("1") # returns the object for ID = 1 @@ -79,17 +79,12 @@ module ActiveRecord # Post.find_by "published_at < ?", 2.weeks.ago def find_by(arg, *args) where(arg, *args).take - rescue ::RangeError - nil end # Like #find_by, except that if no record is found, raises # an ActiveRecord::RecordNotFound error. def find_by!(arg, *args) where(arg, *args).take! - rescue ::RangeError - raise RecordNotFound.new("Couldn't find #{@klass.name} with an out of range value", - @klass.name, @klass.primary_key) end # Gives a record (or N records if a parameter is supplied) without any implied @@ -319,9 +314,7 @@ module ActiveRecord relation = construct_relation_for_exists(conditions) - skip_query_cache_if_necessary { connection.select_value(relation.arel, "#{name} Exists") } ? true : false - rescue ::RangeError - false + skip_query_cache_if_necessary { connection.select_one(relation.arel, "#{name} Exists?") } ? true : false end # This method is called whenever no records are found with either a single @@ -338,14 +331,14 @@ module ActiveRecord name = @klass.name if ids.nil? - error = "Couldn't find #{name}".dup + error = +"Couldn't find #{name}" error << " with#{conditions}" if conditions 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) else - error = "Couldn't find all #{name.pluralize} with '#{key}': ".dup + error = +"Couldn't find all #{name.pluralize} with '#{key}': " 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, key, ids) @@ -359,11 +352,17 @@ module ActiveRecord end def construct_relation_for_exists(conditions) - relation = except(:select, :distinct, :order)._select!(ONE_AS_ONE).limit!(1) + conditions = sanitize_forbidden_attributes(conditions) + + if distinct_value && offset_value + relation = limit(1) + else + relation = except(:select, :distinct, :order)._select!(ONE_AS_ONE).limit!(1) + end case conditions when Array, Hash - relation.where!(conditions) + relation.where!(conditions) unless conditions.empty? else relation.where!(primary_key => conditions) unless conditions == :none end @@ -371,16 +370,8 @@ module ActiveRecord relation end - def construct_join_dependency - including = eager_load_values + includes_values - joins = joins_values.select { |join| join.is_a?(Arel::Nodes::Join) } - ActiveRecord::Associations::JoinDependency.new( - klass, table, including, alias_tracker(joins) - ) - end - - def apply_join_dependency(eager_loading: true) - join_dependency = construct_join_dependency + def apply_join_dependency(eager_loading: group_values.empty?) + join_dependency = construct_join_dependency(eager_load_values + includes_values) relation = except(:includes, :eager_load, :preload).joins!(join_dependency) if eager_loading && !using_limitable_reflections?(join_dependency.reflections) @@ -392,7 +383,6 @@ module ActiveRecord end if block_given? - relation._select!(join_dependency.aliases.columns) yield relation, join_dependency else relation @@ -401,7 +391,7 @@ module ActiveRecord def limited_ids_for(relation) values = @klass.connection.columns_for_distinct( - connection.column_name_from_arel_node(arel_attribute(primary_key)), + connection.visitor.compile(arel_attribute(primary_key)), relation.order_values ) @@ -419,7 +409,7 @@ module ActiveRecord raise UnknownPrimaryKey.new(@klass) if primary_key.nil? expects_array = ids.first.kind_of?(Array) - return ids.first if expects_array && ids.first.empty? + return [] if expects_array && ids.first.empty? ids = ids.flatten.compact.uniq @@ -435,9 +425,6 @@ module ActiveRecord else find_some(ids) end - rescue ::RangeError - 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) @@ -553,8 +540,8 @@ module ActiveRecord end def ordered_relation - if order_values.empty? && primary_key - order(arel_attribute(primary_key).asc) + if order_values.empty? && (implicit_order_column || primary_key) + order(arel_attribute(implicit_order_column || primary_key).asc) else self end diff --git a/activerecord/lib/active_record/relation/merger.rb b/activerecord/lib/active_record/relation/merger.rb index 25510d4a57..6bb77b355c 100644 --- a/activerecord/lib/active_record/relation/merger.rb +++ b/activerecord/lib/active_record/relation/merger.rb @@ -117,20 +117,14 @@ module ActiveRecord if other.klass == relation.klass relation.joins!(*other.joins_values) else - alias_tracker = nil - joins_dependency = other.joins_values.map do |join| + associations, others = other.joins_values.partition do |join| case join - when Hash, Symbol, Array - alias_tracker ||= other.alias_tracker - ActiveRecord::Associations::JoinDependency.new( - other.klass, other.table, join, alias_tracker - ) - else - join + when Hash, Symbol, Array; true end end - relation.joins!(*joins_dependency) + join_dependency = other.construct_join_dependency(associations) + relation.joins!(join_dependency, *others) end end @@ -140,30 +134,19 @@ module ActiveRecord if other.klass == relation.klass relation.left_outer_joins!(*other.left_outer_joins_values) else - alias_tracker = nil - joins_dependency = other.left_outer_joins_values.map do |join| - case join - when Hash, Symbol, Array - alias_tracker ||= other.alias_tracker - ActiveRecord::Associations::JoinDependency.new( - other.klass, other.table, join, alias_tracker - ) - else - join - end - end - - relation.left_outer_joins!(*joins_dependency) + associations = other.left_outer_joins_values + join_dependency = other.construct_join_dependency(associations) + relation.joins!(join_dependency) end end def merge_multi_values if other.reordering_value # override any order specified in the original relation - relation.reorder! other.order_values + relation.reorder!(*other.order_values) elsif other.order_values.any? # merge in order_values from relation - relation.order! other.order_values + relation.order!(*other.order_values) end extensions = other.extensions - relation.extensions @@ -179,9 +162,7 @@ module ActiveRecord end def merge_clauses - if relation.from_clause.empty? && !other.from_clause.empty? - relation.from_clause = other.from_clause - end + relation.from_clause = other.from_clause if replace_from_clause? where_clause = relation.where_clause.merge(other.where_clause) relation.where_clause = where_clause unless where_clause.empty? @@ -189,6 +170,11 @@ module ActiveRecord having_clause = relation.having_clause.merge(other.having_clause) relation.having_clause = having_clause unless having_clause.empty? end + + def replace_from_clause? + relation.from_clause.empty? && !other.from_clause.empty? && + relation.klass.base_class == other.klass.base_class + end end end end diff --git a/activerecord/lib/active_record/relation/predicate_builder.rb b/activerecord/lib/active_record/relation/predicate_builder.rb index 7a0edcbc33..240de3bb69 100644 --- a/activerecord/lib/active_record/relation/predicate_builder.rb +++ b/activerecord/lib/active_record/relation/predicate_builder.rb @@ -27,7 +27,7 @@ module ActiveRecord key else key = key.to_s - key.split(".".freeze).first if key.include?(".".freeze) + key.split(".").first if key.include?(".") end end.compact end @@ -48,7 +48,12 @@ module ActiveRecord end def build(attribute, value) - handler_for(value).call(attribute, value) + if table.type(attribute.name).force_equality?(value) + bind = build_bind_attribute(attribute.name, value) + attribute.eq(bind) + else + handler_for(value).call(attribute, value) + end end def build_bind_attribute(column_name, value) @@ -85,20 +90,21 @@ module ActiveRecord queries.reduce(&:or) elsif table.aggregated_with?(key) mapping = table.reflect_on_aggregation(key).mapping - queries = Array.wrap(value).map do |object| - mapping.map do |field_attr, aggregate_attr| - if mapping.size == 1 && !object.respond_to?(aggregate_attr) - build(table.arel_attribute(field_attr), object) - else - build(table.arel_attribute(field_attr), object.send(aggregate_attr)) - end - end.reduce(&:and) + values = value.nil? ? [nil] : Array.wrap(value) + if mapping.length == 1 || values.empty? + column_name, aggr_attr = mapping.first + values = values.map do |object| + object.respond_to?(aggr_attr) ? object.public_send(aggr_attr) : object + end + build(table.arel_attribute(column_name), values) + else + queries = values.map do |object| + mapping.map do |field_attr, aggregate_attr| + build(table.arel_attribute(field_attr), object.try!(aggregate_attr)) + end.reduce(&:and) + end + queries.reduce(&:or) end - queries.reduce(&:or) - # FIXME: Deprecate this and provide a public API to force equality - elsif (value.is_a?(Range) || value.is_a?(Array)) && - table.type(key.to_s).respond_to?(:subtype) - BasicObjectHandler.new(self).call(table.arel_attribute(key), value) else build(table.arel_attribute(key), value) end @@ -114,11 +120,11 @@ module ActiveRecord def convert_dot_notation_to_hash(attributes) dot_notation = attributes.select do |k, v| - k.include?(".".freeze) && !v.is_a?(Hash) + k.include?(".") && !v.is_a?(Hash) end dot_notation.each_key do |key| - table_name, column_name = key.split(".".freeze) + table_name, column_name = key.split(".") value = attributes.delete(key) attributes[table_name] ||= {} diff --git a/activerecord/lib/active_record/relation/predicate_builder/array_handler.rb b/activerecord/lib/active_record/relation/predicate_builder/array_handler.rb index 64bf83e3c1..ee2ece1560 100644 --- a/activerecord/lib/active_record/relation/predicate_builder/array_handler.rb +++ b/activerecord/lib/active_record/relation/predicate_builder/array_handler.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "active_support/core_ext/array/extract" + module ActiveRecord class PredicateBuilder class ArrayHandler # :nodoc: @@ -11,18 +13,18 @@ module ActiveRecord return attribute.in([]) if value.empty? values = value.map { |x| x.is_a?(Base) ? x.id : x } - nils, values = values.partition(&:nil?) - ranges, values = values.partition { |v| v.is_a?(Range) } + nils = values.extract!(&:nil?) + ranges = values.extract! { |v| v.is_a?(Range) } values_predicate = case values.length when 0 then NullPredicate when 1 then predicate_builder.build(attribute, values.first) else - bind_values = values.map do |v| + values.map! do |v| predicate_builder.build_bind_attribute(attribute.name, v) end - attribute.in(bind_values) + values.empty? ? NullPredicate : attribute.in(values) end unless nils.empty? diff --git a/activerecord/lib/active_record/relation/predicate_builder/range_handler.rb b/activerecord/lib/active_record/relation/predicate_builder/range_handler.rb index 44bb2c7ab6..2ea27c8490 100644 --- a/activerecord/lib/active_record/relation/predicate_builder/range_handler.rb +++ b/activerecord/lib/active_record/relation/predicate_builder/range_handler.rb @@ -3,11 +3,7 @@ module ActiveRecord class PredicateBuilder class RangeHandler # :nodoc: - class RangeWithBinds < Struct.new(:begin, :end) - def exclude_end? - false - end - end + RangeWithBinds = Struct.new(:begin, :end, :exclude_end?) def initialize(predicate_builder) @predicate_builder = predicate_builder @@ -16,22 +12,7 @@ module ActiveRecord def call(attribute, value) begin_bind = predicate_builder.build_bind_attribute(attribute.name, value.begin) end_bind = predicate_builder.build_bind_attribute(attribute.name, value.end) - - if begin_bind.value.infinity? - if end_bind.value.infinity? - attribute.not_in([]) - elsif value.exclude_end? - attribute.lt(end_bind) - else - attribute.lteq(end_bind) - end - elsif end_bind.value.infinity? - attribute.gteq(begin_bind) - elsif value.exclude_end? - attribute.gteq(begin_bind).and(attribute.lt(end_bind)) - else - attribute.between(RangeWithBinds.new(begin_bind, end_bind)) - end + attribute.between(RangeWithBinds.new(begin_bind, end_bind, value.exclude_end?)) end private diff --git a/activerecord/lib/active_record/relation/query_attribute.rb b/activerecord/lib/active_record/relation/query_attribute.rb index f64bd30d38..cd18f27330 100644 --- a/activerecord/lib/active_record/relation/query_attribute.rb +++ b/activerecord/lib/active_record/relation/query_attribute.rb @@ -18,24 +18,31 @@ module ActiveRecord end def nil? - !value_before_type_cast.is_a?(StatementCache::Substitute) && - (value_before_type_cast.nil? || value_for_database.nil?) + unless value_before_type_cast.is_a?(StatementCache::Substitute) + value_before_type_cast.nil? || + type.respond_to?(:subtype, true) && value_for_database.nil? + end + rescue ::RangeError end - def boundable? - return @_boundable if defined?(@_boundable) - nil? - @_boundable = true + def infinite? + infinity?(value_before_type_cast) || infinity?(value_for_database) rescue ::RangeError - @_boundable = false end - def infinity? - _infinity?(value_before_type_cast) || boundable? && _infinity?(value_for_database) + def unboundable? + if defined?(@_unboundable) + @_unboundable + else + value_for_database unless value_before_type_cast.is_a?(StatementCache::Substitute) + @_unboundable = nil + end + rescue ::RangeError + @_unboundable = type.cast(value_before_type_cast) <=> 0 end private - def _infinity?(value) + def infinity?(value) value.respond_to?(:infinite?) && value.infinite? end end diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 4e60863e52..90b5e9a118 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -100,7 +100,7 @@ module ActiveRecord # # === conditions # - # If you want to add conditions to your included models you'll have + # If you want to add string conditions to your included models, you'll have # to explicitly reference them. For example: # # User.includes(:posts).where('posts.name = ?', 'example') @@ -111,6 +111,12 @@ module ActiveRecord # # Note that #includes works with association names while #references needs # the actual table name. + # + # If you pass the conditions via hash, you don't need to call #references + # explicitly, as #where references the tables for you. For example, this + # will work correctly: + # + # User.includes(:posts).where(posts: { name: 'example' }) def includes(*args) check_if_method_has_arguments!(:includes, args) spawn.includes!(*args) @@ -154,6 +160,19 @@ module ActiveRecord self end + # Extracts a named +association+ from the relation. The named association is first preloaded, + # then the individual association records are collected from the relation. Like so: + # + # account.memberships.extract_associated(:user) + # # => Returns collection of User records + # + # This is short-hand for: + # + # account.memberships.preload(:user).collect(&:user) + def extract_associated(association) + preload(association).collect(&association) + end + # Use to indicate that the given +table_names+ are referenced by an SQL string, # and should therefore be JOINed in any query rather than loaded separately. # This method only works in conjunction with #includes. @@ -233,13 +252,31 @@ module ActiveRecord def _select!(*fields) # :nodoc: fields.reject!(&:blank?) fields.flatten! - fields.map! do |field| - klass.attribute_alias?(field) ? klass.attribute_alias(field).to_sym : field - end self.select_values += fields self end + # Allows you to change a previously set select statement. + # + # Post.select(:title, :body) + # # SELECT `posts`.`title`, `posts`.`body` FROM `posts` + # + # Post.select(:title, :body).reselect(:created_at) + # # SELECT `posts`.`created_at` FROM `posts` + # + # This is short-hand for <tt>unscope(:select).select(fields)</tt>. + # Note that we're unscoping the entire select statement. + def reselect(*args) + check_if_method_has_arguments!(:reselect, args) + spawn.reselect!(*args) + end + + # Same as #reselect but operates on relation in-place instead of copying. + def reselect!(*args) # :nodoc: + self.select_values = args + self + end + # Allows to specify a group attribute: # # User.group(:name) @@ -328,8 +365,8 @@ module ActiveRecord end VALID_UNSCOPING_VALUES = Set.new([:where, :select, :group, :order, :lock, - :limit, :offset, :joins, :left_outer_joins, - :includes, :from, :readonly, :having]) + :limit, :offset, :joins, :left_outer_joins, :annotate, + :includes, :from, :readonly, :having, :optimizer_hints]) # Removes an unwanted relation that is already defined on a chain of relations. # This is useful when passing around chains of relations and would like to @@ -880,6 +917,29 @@ module ActiveRecord self end + # Specify optimizer hints to be used in the SELECT statement. + # + # Example (for MySQL): + # + # Topic.optimizer_hints("MAX_EXECUTION_TIME(50000)", "NO_INDEX_MERGE(topics)") + # # SELECT /*+ MAX_EXECUTION_TIME(50000) NO_INDEX_MERGE(topics) */ `topics`.* FROM `topics` + # + # Example (for PostgreSQL with pg_hint_plan): + # + # Topic.optimizer_hints("SeqScan(topics)", "Parallel(topics 8)") + # # SELECT /*+ SeqScan(topics) Parallel(topics 8) */ "topics".* FROM "topics" + def optimizer_hints(*args) + check_if_method_has_arguments!(:optimizer_hints, args) + spawn.optimizer_hints!(*args) + end + + def optimizer_hints!(*args) # :nodoc: + args.flatten! + + self.optimizer_hints_values += args + self + end + # Reverse the existing order clause on the relation. # # User.order('name ASC').reverse_order # generated SQL has 'ORDER BY name DESC' @@ -894,8 +954,8 @@ module ActiveRecord self end - def skip_query_cache! # :nodoc: - self.skip_query_cache_value = true + def skip_query_cache!(value = true) # :nodoc: + self.skip_query_cache_value = value self end @@ -904,25 +964,58 @@ module ActiveRecord self end + # Adds an SQL comment to queries generated from this relation. For example: + # + # User.annotate("selecting user names").select(:name) + # # SELECT "users"."name" FROM "users" /* selecting user names */ + # + # User.annotate("selecting", "user", "names").select(:name) + # # SELECT "users"."name" FROM "users" /* selecting */ /* user */ /* names */ + # + # The SQL block comment delimiters, "/*" and "*/", will be added automatically. + def annotate(*args) + check_if_method_has_arguments!(:annotate, args) + spawn.annotate!(*args) + end + + # Like #annotate, but modifies relation in place. + def annotate!(*args) # :nodoc: + self.annotate_values += args + self + end + # Returns the Arel object associated with the relation. def arel(aliases = nil) # :nodoc: @arel ||= build_arel(aliases) end + def construct_join_dependency(associations) # :nodoc: + ActiveRecord::Associations::JoinDependency.new( + klass, table, associations + ) + end + protected + def build_subquery(subquery_alias, select_value) # :nodoc: + subquery = except(:optimizer_hints).arel.as(subquery_alias) + + Arel::SelectManager.new(subquery).project(select_value).tap do |arel| + arel.optimizer_hints(*optimizer_hints_values) unless optimizer_hints_values.empty? + end + end + + private # Returns a relation value with a given name - def get_value(name) # :nodoc: + def get_value(name) @values.fetch(name, DEFAULT_VALUES[name]) end # Sets the relation value with the given name - def set_value(name, value) # :nodoc: + def set_value(name, value) assert_mutability! @values[name] = value end - private - def assert_mutability! raise ImmutableRelation if @loaded raise ImmutableRelation if defined?(@arel) && @arel @@ -931,14 +1024,17 @@ module ActiveRecord def build_arel(aliases) arel = Arel::SelectManager.new(table) - aliases = build_joins(arel, joins_values.flatten, aliases) unless joins_values.empty? - build_left_outer_joins(arel, left_outer_joins_values.flatten, aliases) unless left_outer_joins_values.empty? + if !joins_values.empty? + build_joins(arel, joins_values.flatten, aliases) + elsif !left_outer_joins_values.empty? + build_left_outer_joins(arel, left_outer_joins_values.flatten, aliases) + end arel.where(where_clause.ast) unless where_clause.empty? arel.having(having_clause.ast) unless having_clause.empty? if limit_value limit_attribute = ActiveModel::Attribute.with_cast_value( - "LIMIT".freeze, + "LIMIT", connection.sanitize_limit(limit_value), Type.default_value, ) @@ -946,7 +1042,7 @@ module ActiveRecord end if offset_value offset_attribute = ActiveModel::Attribute.with_cast_value( - "OFFSET".freeze, + "OFFSET", offset_value.to_i, Type.default_value, ) @@ -958,9 +1054,11 @@ module ActiveRecord build_select(arel) + arel.optimizer_hints(*optimizer_hints_values) unless optimizer_hints_values.empty? arel.distinct(distinct_value) arel.from(build_from) unless from_clause.empty? arel.lock(lock_value) if lock_value + arel.comment(*annotate_values) unless annotate_values.empty? arel end @@ -980,22 +1078,28 @@ module ActiveRecord end end - def build_left_outer_joins(manager, outer_joins, aliases) - buckets = outer_joins.group_by do |join| - case join + def valid_association_list(associations) + associations.each do |association| + case association when Hash, Symbol, Array - :association_join - when ActiveRecord::Associations::JoinDependency - :stashed_join + # valid else raise ArgumentError, "only Hash, Symbol and Array are allowed" end end + end + def build_left_outer_joins(manager, outer_joins, aliases) + buckets = { association_join: valid_association_list(outer_joins) } build_join_query(manager, buckets, Arel::Nodes::OuterJoin, aliases) end def build_joins(manager, joins, aliases) + unless left_outer_joins_values.empty? + left_joins = valid_association_list(left_outer_joins_values.flatten) + joins << construct_join_dependency(left_joins) + end + buckets = joins.group_by do |join| case join when String @@ -1017,19 +1121,17 @@ module ActiveRecord def build_join_query(manager, buckets, join_type, aliases) buckets.default = [] - association_joins = buckets[:association_join] - stashed_association_joins = buckets[:stashed_join] - join_nodes = buckets[:join_node].uniq - string_joins = buckets[:string_join].map(&:strip).uniq + association_joins = buckets[:association_join] + stashed_joins = buckets[:stashed_join] + join_nodes = buckets[:join_node].uniq + string_joins = buckets[:string_join].map(&:strip).uniq join_list = join_nodes + convert_join_strings_to_ast(string_joins) alias_tracker = alias_tracker(join_list, aliases) - join_dependency = ActiveRecord::Associations::JoinDependency.new( - klass, table, association_joins, alias_tracker - ) + join_dependency = construct_join_dependency(association_joins) - joins = join_dependency.join_constraints(stashed_association_joins, join_type) + joins = join_dependency.join_constraints(stashed_joins, join_type, alias_tracker) joins.each { |join| manager.from(join) } manager.join_sources.concat(join_list) @@ -1055,17 +1157,36 @@ module ActiveRecord end def arel_columns(columns) - columns.map do |field| - if (Symbol === field || String === field) && (klass.has_attribute?(field) || klass.attribute_alias?(field)) && !from_clause.value - arel_attribute(field) - elsif Symbol === field - connection.quote_table_name(field.to_s) + columns.flat_map do |field| + case field + when Symbol + field = field.to_s + arel_column(field, &connection.method(:quote_table_name)) + when String + arel_column(field, &:itself) + when Proc + field.call else field end end end + def arel_column(field) + field = klass.attribute_alias(field) if klass.attribute_alias?(field) + from = from_clause.name || from_clause.value + + if klass.columns_hash.key?(field) && (!from || table_name_matches?(from)) + arel_attribute(field) + else + yield field + end + end + + def table_name_matches?(from) + /(?:\A|(?<!FROM)\s)(?:\b#{table.name}\b|#{connection.quote_table_name(table.name)})(?!\.)/i.match?(from.to_s) + end + def reverse_sql_order(order_query) if order_query.empty? return [arel_attribute(primary_key).desc] if primary_key @@ -1081,7 +1202,7 @@ module ActiveRecord o.reverse when String if does_not_support_reverse?(o) - raise IrreversibleOrderError, "Order #{o.inspect} can not be reversed automatically" + raise IrreversibleOrderError, "Order #{o.inspect} cannot be reversed automatically" end o.split(",").map! do |s| s.strip! @@ -1101,7 +1222,7 @@ module ActiveRecord # Uses SQL function with multiple arguments. (order.include?(",") && order.split(",").find { |section| section.count("(") != section.count(")") }) || # Uses "nulls first" like construction. - /nulls (first|last)\Z/i.match?(order) + /\bnulls\s+(?:first|last)\b/i.match?(order) end def build_order(arel) @@ -1132,9 +1253,9 @@ module ActiveRecord end order_args.flatten! - @klass.enforce_raw_sql_whitelist( + @klass.disallow_raw_sql!( order_args.flat_map { |a| a.is_a?(Hash) ? a.keys : a }, - whitelist: AttributeMethods::ClassMethods::COLUMN_NAME_ORDER_WHITELIST + permit: AttributeMethods::ClassMethods::COLUMN_NAME_WITH_ORDER ) validate_order_args(order_args) @@ -1147,14 +1268,20 @@ module ActiveRecord order_args.map! do |arg| case arg when Symbol - arel_attribute(arg).asc + arg = arg.to_s + arel_column(arg) { + Arel.sql(connection.quote_table_name(arg)) + }.asc when Hash arg.map { |field, dir| case field when Arel::Nodes::SqlLiteral field.send(dir.downcase) else - arel_attribute(field).send(dir.downcase) + field = field.to_s + arel_column(field) { + Arel.sql(connection.quote_table_name(field)) + }.send(dir.downcase) end } else @@ -1187,8 +1314,9 @@ module ActiveRecord STRUCTURAL_OR_METHODS = Relation::VALUE_METHODS - [:extending, :where, :having, :unscope, :references] def structurally_incompatible_values_for_or(other) + values = other.values STRUCTURAL_OR_METHODS.reject do |method| - get_value(method) == other.get_value(method) + get_value(method) == values.fetch(method, DEFAULT_VALUES[method]) end end diff --git a/activerecord/lib/active_record/relation/spawn_methods.rb b/activerecord/lib/active_record/relation/spawn_methods.rb index b092399657..efc4b447aa 100644 --- a/activerecord/lib/active_record/relation/spawn_methods.rb +++ b/activerecord/lib/active_record/relation/spawn_methods.rb @@ -8,9 +8,8 @@ module ActiveRecord module SpawnMethods # This is overridden by Associations::CollectionProxy def spawn #:nodoc: - clone + already_in_scope? ? klass.all : clone end - alias :all :spawn # Merges in the conditions from <tt>other</tt>, if <tt>other</tt> is an ActiveRecord::Relation. # Returns an array representing the intersection of the resulting records with <tt>other</tt>, if <tt>other</tt> is an array. diff --git a/activerecord/lib/active_record/relation/where_clause.rb b/activerecord/lib/active_record/relation/where_clause.rb index a502713e56..47728aac30 100644 --- a/activerecord/lib/active_record/relation/where_clause.rb +++ b/activerecord/lib/active_record/relation/where_clause.rb @@ -125,6 +125,10 @@ module ActiveRecord raise ArgumentError, "Invalid argument for .where.not(), got nil." when Arel::Nodes::In Arel::Nodes::NotIn.new(node.left, node.right) + when Arel::Nodes::IsNotDistinctFrom + Arel::Nodes::IsDistinctFrom.new(node.left, node.right) + when Arel::Nodes::IsDistinctFrom + Arel::Nodes::IsNotDistinctFrom.new(node.left, node.right) when Arel::Nodes::Equality Arel::Nodes::NotEqual.new(node.left, node.right) when String @@ -136,11 +140,7 @@ module ActiveRecord def except_predicates(columns) 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) - columns.include?(subrelation.name.to_s) - end + Arel.fetch_attribute(node) { |attr| columns.include?(attr.name.to_s) } end end diff --git a/activerecord/lib/active_record/result.rb b/activerecord/lib/active_record/result.rb index e54e8086dd..da6d10b6ec 100644 --- a/activerecord/lib/active_record/result.rb +++ b/activerecord/lib/active_record/result.rb @@ -21,7 +21,7 @@ module ActiveRecord # ] # # # Get an array of hashes representing the result (column => value): - # result.to_hash + # result.to_a # # => [{"id" => 1, "title" => "title_1", "body" => "body_1"}, # {"id" => 2, "title" => "title_2", "body" => "body_2"}, # ... @@ -43,6 +43,11 @@ module ActiveRecord @column_types = column_types end + # Returns true if this result set includes the column named +name+ + def includes_column?(name) + @columns.include? name + end + # Returns the number of elements in the rows array. def length @rows.length @@ -60,9 +65,12 @@ module ActiveRecord end end - # Returns an array of hashes representing each row record. def to_hash - hash_rows + ActiveSupport::Deprecation.warn(<<-MSG.squish) + `ActiveRecord::Result#to_hash` has been renamed to `to_a`. + `to_hash` is deprecated and will be removed in Rails 6.1. + MSG + to_a end alias :map! :map @@ -78,6 +86,8 @@ module ActiveRecord hash_rows end + alias :to_a :to_ary + def [](idx) hash_rows[idx] end @@ -97,12 +107,21 @@ module ActiveRecord end def cast_values(type_overrides = {}) # :nodoc: - types = columns.map { |name| column_type(name, type_overrides) } - result = rows.map do |values| - types.zip(values).map { |type, value| type.deserialize(value) } - end + if columns.one? + # Separated to avoid allocating an array per row + + type = column_type(columns.first, type_overrides) - columns.one? ? result.map!(&:first) : result + rows.map do |(value)| + type.deserialize(value) + end + else + types = columns.map { |name| column_type(name, type_overrides) } + + rows.map do |values| + Array.new(values.size) { |i| types[i].deserialize(values[i]) } + end + end end def initialize_copy(other) @@ -125,7 +144,9 @@ module ActiveRecord begin # We freeze the strings to prevent them getting duped when # used as keys in ActiveRecord::Base's @attributes hash - columns = @columns.map { |c| c.dup.freeze } + columns = @columns.map(&:-@) + length = columns.length + @rows.map { |row| # In the past we used Hash[columns.zip(row)] # though elegant, the verbose way is much more efficient @@ -134,8 +155,6 @@ module ActiveRecord hash = {} index = 0 - length = columns.length - while index < length hash[columns[index]] = row[index] index += 1 diff --git a/activerecord/lib/active_record/sanitization.rb b/activerecord/lib/active_record/sanitization.rb index c6c268855e..750766714d 100644 --- a/activerecord/lib/active_record/sanitization.rb +++ b/activerecord/lib/active_record/sanitization.rb @@ -61,8 +61,8 @@ module ActiveRecord # # => "id ASC" def sanitize_sql_for_order(condition) if condition.is_a?(Array) && condition.first.to_s.include?("?") - enforce_raw_sql_whitelist([condition.first], - whitelist: AttributeMethods::ClassMethods::COLUMN_NAME_ORDER_WHITELIST + disallow_raw_sql!([condition.first], + permit: AttributeMethods::ClassMethods::COLUMN_NAME_WITH_ORDER ) # Ensure we aren't dealing with a subclass of String that might @@ -134,43 +134,6 @@ module ActiveRecord end private - # Accepts a hash of SQL conditions and replaces those attributes - # that correspond to a {#composed_of}[rdoc-ref:Aggregations::ClassMethods#composed_of] - # relationship with their expanded aggregate attribute values. - # - # Given: - # - # class Person < ActiveRecord::Base - # composed_of :address, class_name: "Address", - # mapping: [%w(address_street street), %w(address_city city)] - # end - # - # Then: - # - # { address: Address.new("813 abc st.", "chicago") } - # # => { address_street: "813 abc st.", address_city: "chicago" } - def expand_hash_conditions_for_aggregates(attrs) # :doc: - expanded_attrs = {} - attrs.each do |attr, value| - if aggregation = reflect_on_aggregation(attr.to_sym) - mapping = aggregation.mapping - mapping.each do |field_attr, aggregate_attr| - expanded_attrs[field_attr] = if value.is_a?(Array) - value.map { |it| it.send(aggregate_attr) } - elsif mapping.size == 1 && !value.respond_to?(aggregate_attr) - value - else - value.send(aggregate_attr) - end - end - else - expanded_attrs[attr] = value - end - end - expanded_attrs - end - deprecate :expand_hash_conditions_for_aggregates - def replace_bind_variables(statement, values) raise_if_bind_arity_mismatch(statement, statement.count("?"), values.size) bound = values.dup @@ -202,10 +165,11 @@ module ActiveRecord def quote_bound_value(value, c = connection) if value.respond_to?(:map) && !value.acts_like?(:string) - if value.respond_to?(:empty?) && value.empty? + quoted = value.map { |v| c.quote(v) } + if quoted.empty? c.quote(nil) else - value.map { |v| c.quote(v) }.join(",") + quoted.join(",") end else c.quote(value) diff --git a/activerecord/lib/active_record/schema.rb b/activerecord/lib/active_record/schema.rb index 216359867c..76bf53387d 100644 --- a/activerecord/lib/active_record/schema.rb +++ b/activerecord/lib/active_record/schema.rb @@ -51,20 +51,11 @@ module ActiveRecord if info[:version].present? ActiveRecord::SchemaMigration.create_table - connection.assume_migrated_upto_version(info[:version], migrations_paths) + connection.assume_migrated_upto_version(info[:version]) end ActiveRecord::InternalMetadata.create_table ActiveRecord::InternalMetadata[:environment] = connection.migration_context.current_environment end - - private - # Returns the migrations paths. - # - # ActiveRecord::Schema.new.migrations_paths - # # => ["db/migrate"] # Rails migration path by default. - def migrations_paths - ActiveRecord::Migrator.migrations_paths - end end end diff --git a/activerecord/lib/active_record/schema_dumper.rb b/activerecord/lib/active_record/schema_dumper.rb index 9974c28445..2f7cc07221 100644 --- a/activerecord/lib/active_record/schema_dumper.rb +++ b/activerecord/lib/active_record/schema_dumper.rb @@ -47,6 +47,7 @@ module ActiveRecord end private + attr_accessor :table_name def initialize(connection, options = {}) @connection = connection @@ -71,11 +72,11 @@ module ActiveRecord # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # -# Note that this schema.rb definition is the authoritative source for your -# database schema. If you need to create the application database on another -# system, you should be using db:schema:load, not running all the migrations -# from scratch. The latter is a flawed and unsustainable approach (the more migrations -# you'll amass, the slower it'll run and the greater likelihood for issues). +# This file is the source Rails uses to define your schema when running `rails +# db:schema:load`. When creating a new database, `rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. @@ -110,6 +111,8 @@ HEADER def table(table, stream) columns = @connection.columns(table) begin + self.table_name = table + tbl = StringIO.new # first dump primary key column @@ -159,6 +162,8 @@ HEADER stream.puts "# Could not dump table #{table.inspect} because of following #{e.class}" stream.puts "# #{e.message}" stream.puts + ensure + self.table_name = nil end end diff --git a/activerecord/lib/active_record/schema_migration.rb b/activerecord/lib/active_record/schema_migration.rb index f2d8b038fa..74547de862 100644 --- a/activerecord/lib/active_record/schema_migration.rb +++ b/activerecord/lib/active_record/schema_migration.rb @@ -10,12 +10,16 @@ module ActiveRecord # to be executed the next time. class SchemaMigration < ActiveRecord::Base # :nodoc: class << self + def _internal? + true + end + def primary_key "version" end def table_name - "#{table_name_prefix}#{ActiveRecord::Base.schema_migrations_table_name}#{table_name_suffix}" + "#{table_name_prefix}#{schema_migrations_table_name}#{table_name_suffix}" end def table_exists? diff --git a/activerecord/lib/active_record/scoping.rb b/activerecord/lib/active_record/scoping.rb index 01ac56570a..35e9dcbffc 100644 --- a/activerecord/lib/active_record/scoping.rb +++ b/activerecord/lib/active_record/scoping.rb @@ -12,14 +12,6 @@ module ActiveRecord end module ClassMethods # :nodoc: - def current_scope(skip_inherited_scope = false) - ScopeRegistry.value_for(:current_scope, self, skip_inherited_scope) - end - - 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 @@ -30,6 +22,14 @@ module ActiveRecord def scope_attributes? current_scope end + + def current_scope(skip_inherited_scope = false) + ScopeRegistry.value_for(:current_scope, self, skip_inherited_scope) + end + + def current_scope=(scope) + ScopeRegistry.set_value_for(:current_scope, self, scope) + end end def populate_with_current_scope_attributes # :nodoc: diff --git a/activerecord/lib/active_record/scoping/default.rb b/activerecord/lib/active_record/scoping/default.rb index 8c612df27a..87bcfd5181 100644 --- a/activerecord/lib/active_record/scoping/default.rb +++ b/activerecord/lib/active_record/scoping/default.rb @@ -86,8 +86,8 @@ module ActiveRecord # # Should return a scope, you can call 'super' here etc. # end # end - def default_scope(scope = nil) # :doc: - scope = Proc.new if block_given? + def default_scope(scope = nil, &block) # :doc: + scope = block if block_given? if scope.is_a?(Relation) || !scope.respond_to?(:call) raise ArgumentError, @@ -100,7 +100,7 @@ module ActiveRecord self.default_scopes += [scope] end - def build_default_scope(base_rel = nil) + def build_default_scope(relation = relation()) return if abstract_class? if default_scope_override.nil? @@ -111,15 +111,14 @@ module ActiveRecord # The user has defined their own default scope method, so call that evaluate_default_scope do if scope = default_scope - (base_rel ||= relation).merge!(scope) + relation.merge!(scope) end end elsif default_scopes.any? - base_rel ||= relation evaluate_default_scope do - default_scopes.inject(base_rel) do |default_scope, scope| + default_scopes.inject(relation) do |default_scope, scope| scope = scope.respond_to?(:to_proc) ? scope : scope.method(:call) - default_scope.merge!(base_rel.instance_exec(&scope)) + default_scope.instance_exec(&scope) || default_scope end end end diff --git a/activerecord/lib/active_record/scoping/named.rb b/activerecord/lib/active_record/scoping/named.rb index a784001587..cd9801b7a0 100644 --- a/activerecord/lib/active_record/scoping/named.rb +++ b/activerecord/lib/active_record/scoping/named.rb @@ -24,13 +24,21 @@ 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 + scope = current_scope - if current_scope - if self == current_scope.klass - current_scope.clone + if scope + if scope._deprecated_scope_source + ActiveSupport::Deprecation.warn(<<~MSG.squish) + Class level methods will no longer inherit scoping from `#{scope._deprecated_scope_source}` + in Rails 6.1. To continue using the scoped relation, pass it into the block directly. + To instead access the full set of models, as Rails 6.1 will, use `#{name}.unscoped`. + MSG + end + + if self == scope.klass + scope.clone else - relation.merge!(current_scope) + relation.merge!(scope) end else default_scoped @@ -38,9 +46,7 @@ module ActiveRecord end def scope_for_association(scope = relation) # :nodoc: - current_scope = self.current_scope - - if current_scope && current_scope.empty_scope? + if current_scope&.empty_scope? scope else default_scoped(scope) @@ -52,7 +58,7 @@ module ActiveRecord end def default_extensions # :nodoc: - if scope = current_scope || build_default_scope + if scope = scope_for_association || build_default_scope scope.extensions else [] @@ -181,20 +187,20 @@ module ActiveRecord extension = Module.new(&block) if block if body.respond_to?(:to_proc) - singleton_class.send(:define_method, name) do |*args| - scope = all - scope = scope._exec_scope(*args, &body) + singleton_class.define_method(name) do |*args| + scope = all._exec_scope(name, *args, &body) scope = scope.extending(extension) if extension scope end else - singleton_class.send(:define_method, name) do |*args| - scope = all - scope = scope.scoping { body.call(*args) || scope } + singleton_class.define_method(name) do |*args| + scope = body.call(*args) || all scope = scope.extending(extension) if extension scope end end + + generate_relation_method(name) end private diff --git a/activerecord/lib/active_record/statement_cache.rb b/activerecord/lib/active_record/statement_cache.rb index b41d3504fd..93bce15230 100644 --- a/activerecord/lib/active_record/statement_cache.rb +++ b/activerecord/lib/active_record/statement_cache.rb @@ -44,7 +44,7 @@ module ActiveRecord def initialize(values) @values = values @indexes = values.each_with_index.find_all { |thing, i| - Arel::Nodes::BindParam === thing + Substitute === thing }.map(&:last) end @@ -56,6 +56,28 @@ module ActiveRecord end end + class PartialQueryCollector + def initialize + @parts = [] + @binds = [] + end + + def <<(str) + @parts << str + self + end + + def add_bind(obj) + @binds << obj + @parts << Substitute.new + self + end + + def value + [@parts, @binds] + end + end + def self.query(sql) Query.new(sql) end @@ -64,6 +86,10 @@ module ActiveRecord PartialQuery.new(values) end + def self.partial_query_collector + PartialQueryCollector.new + end + class Params # :nodoc: def bind; Substitute.new; end end @@ -87,8 +113,8 @@ module ActiveRecord end end - def self.create(connection, block = Proc.new) - relation = block.call Params.new + def self.create(connection, callable = nil, &block) + relation = (callable || block).call Params.new query_builder, binds = connection.cacheable_query(self, relation.arel) bind_map = BindMap.new(binds) new(query_builder, bind_map, relation.klass) @@ -106,6 +132,8 @@ module ActiveRecord sql = query_builder.sql_for bind_values, connection klass.find_by_sql(sql, bind_values, preparable: true, &block) + rescue ::RangeError + nil end def self.unsupported_value?(value) diff --git a/activerecord/lib/active_record/store.rb b/activerecord/lib/active_record/store.rb index 8d628359c3..6fecb06897 100644 --- a/activerecord/lib/active_record/store.rb +++ b/activerecord/lib/active_record/store.rb @@ -11,6 +11,12 @@ module ActiveRecord # of the model. This is very helpful for easily exposing store keys to a form or elsewhere that's # already built around just accessing attributes on the model. # + # Every accessor comes with dirty tracking methods (+key_changed?+, +key_was+ and +key_change+) and + # methods to access the changes made during the last save (+saved_change_to_key?+, +saved_change_to_key+ and + # +key_before_last_save+). + # + # NOTE: There is no +key_will_change!+ method for accessors, use +store_will_change!+ instead. + # # Make sure that you declare the database column used for the serialized store as a text, so there's # plenty of room. # @@ -33,27 +39,38 @@ module ActiveRecord # store :settings, accessors: [ :color, :homepage ], coder: JSON # store :parent, accessors: [ :name ], coder: JSON, prefix: true # store :spouse, accessors: [ :name ], coder: JSON, prefix: :partner + # store :settings, accessors: [ :two_factor_auth ], suffix: true + # store :settings, accessors: [ :login_retry ], suffix: :config # end # # u = User.new(color: 'black', homepage: '37signals.com', parent_name: 'Mary', partner_name: 'Lily') # u.color # Accessor stored attribute # u.parent_name # Accessor stored attribute with prefix # u.partner_name # Accessor stored attribute with custom prefix + # u.two_factor_auth_settings # Accessor stored attribute with suffix + # u.login_retry_config # Accessor stored attribute with custom suffix # u.settings[:country] = 'Denmark' # Any attribute, even if not specified with an accessor # # # There is no difference between strings and symbols for accessing custom attributes # u.settings[:country] # => 'Denmark' # u.settings['country'] # => 'Denmark' # + # # Dirty tracking + # u.color = 'green' + # u.color_changed? # => true + # u.color_was # => 'black' + # u.color_change # => ['black', 'red'] + # # # Add additional accessors to an existing store through store_accessor # class SuperUser < User # store_accessor :settings, :privileges, :servants # store_accessor :parent, :birthday, prefix: true + # store_accessor :settings, :secret_question, suffix: :config # end # # The stored attribute names can be retrieved using {.stored_attributes}[rdoc-ref:rdoc-ref:ClassMethods#stored_attributes]. # - # User.stored_attributes[:settings] # [:color, :homepage] + # User.stored_attributes[:settings] # [:color, :homepage, :two_factor_auth, :login_retry] # # == Overwriting default accessors # @@ -86,10 +103,10 @@ module ActiveRecord module ClassMethods def store(store_attribute, options = {}) serialize store_attribute, IndifferentCoder.new(store_attribute, options[:coder]) - store_accessor(store_attribute, options[:accessors], prefix: options[:prefix]) if options.has_key? :accessors + store_accessor(store_attribute, options[:accessors], options.slice(:prefix, :suffix)) if options.has_key? :accessors end - def store_accessor(store_attribute, *keys, prefix: nil) + def store_accessor(store_attribute, *keys, prefix: nil, suffix: nil) keys = keys.flatten accessor_prefix = @@ -101,16 +118,63 @@ module ActiveRecord else "" end + accessor_suffix = + case suffix + when String, Symbol + "_#{suffix}" + when TrueClass + "_#{store_attribute}" + else + "" + end _store_accessors_module.module_eval do keys.each do |key| - define_method("#{accessor_prefix}#{key}=") do |value| + accessor_key = "#{accessor_prefix}#{key}#{accessor_suffix}" + + define_method("#{accessor_key}=") do |value| write_store_attribute(store_attribute, key, value) end - define_method("#{accessor_prefix}#{key}") do + define_method(accessor_key) do read_store_attribute(store_attribute, key) end + + define_method("#{accessor_key}_changed?") do + return false unless attribute_changed?(store_attribute) + prev_store, new_store = changes[store_attribute] + prev_store&.dig(key) != new_store&.dig(key) + end + + define_method("#{accessor_key}_change") do + return unless attribute_changed?(store_attribute) + prev_store, new_store = changes[store_attribute] + [prev_store&.dig(key), new_store&.dig(key)] + end + + define_method("#{accessor_key}_was") do + return unless attribute_changed?(store_attribute) + prev_store, _new_store = changes[store_attribute] + prev_store&.dig(key) + end + + define_method("saved_change_to_#{accessor_key}?") do + return false unless saved_change_to_attribute?(store_attribute) + prev_store, new_store = saved_change_to_attribute(store_attribute) + prev_store&.dig(key) != new_store&.dig(key) + end + + define_method("saved_change_to_#{accessor_key}") do + return unless saved_change_to_attribute?(store_attribute) + prev_store, new_store = saved_change_to_attribute(store_attribute) + [prev_store&.dig(key), new_store&.dig(key)] + end + + define_method("#{accessor_key}_before_last_save") do + return unless saved_change_to_attribute?(store_attribute) + prev_store, _new_store = saved_change_to_attribute(store_attribute) + prev_store&.dig(key) + end end end diff --git a/activerecord/lib/active_record/tasks/database_tasks.rb b/activerecord/lib/active_record/tasks/database_tasks.rb index 521375954b..7285c15477 100644 --- a/activerecord/lib/active_record/tasks/database_tasks.rb +++ b/activerecord/lib/active_record/tasks/database_tasks.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "active_record/database_configurations" + module ActiveRecord module Tasks # :nodoc: class DatabaseAlreadyExists < StandardError; end # :nodoc: @@ -8,7 +10,7 @@ module ActiveRecord # ActiveRecord::Tasks::DatabaseTasks is a utility class, which encapsulates # logic behind common tasks used to manage database and migrations. # - # The tasks defined here are used with Rake tasks provided by Active Record. + # The tasks defined here are used with Rails commands provided by Active Record. # # In order to use DatabaseTasks, a few config values need to be set. All the needed # config values are set by Rails already, so it's necessary to do it only if you @@ -101,16 +103,21 @@ module ActiveRecord @env ||= Rails.env end + def spec + @spec ||= "primary" + end + def seed_loader @seed_loader ||= Rails.application end def current_config(options = {}) options.reverse_merge! env: env + options[:spec] ||= "primary" if options.has_key?(:config) @current_config = options[:config] else - @current_config ||= ActiveRecord::Base.configurations[options[:env]] + @current_config ||= ActiveRecord::Base.configurations.configs_for(env_name: options[:env], spec_name: options[:spec]).config end end @@ -122,7 +129,7 @@ module ActiveRecord $stderr.puts "Database '#{configuration['database']}' already exists" if verbose? rescue Exception => error $stderr.puts error - $stderr.puts "Couldn't create database for #{configuration.inspect}" + $stderr.puts "Couldn't create '#{configuration['database']}' database. Please check your configuration." raise end @@ -136,7 +143,7 @@ module ActiveRecord def for_each databases = Rails.application.config.load_database_yaml - database_configs = ActiveRecord::DatabaseConfigurations.configs_for(Rails.env, databases) + database_configs = ActiveRecord::DatabaseConfigurations.new(databases).configs_for(env_name: Rails.env) # if this is a single database application we don't want tasks for each primary database return if database_configs.count == 1 @@ -175,19 +182,55 @@ module ActiveRecord } end + def truncate_tables(configuration) + ActiveRecord::Base.connected_to(database: { truncation: configuration }) do + table_names = ActiveRecord::Base.connection.tables + table_names -= [ + SchemaMigration.table_name, + InternalMetadata.table_name + ] + + ActiveRecord::Base.connection.truncate_tables(*table_names) + end + end + private :truncate_tables + + def truncate_all(environment = env) + ActiveRecord::Base.configurations.configs_for(env_name: environment).each do |db_config| + truncate_tables db_config.config + end + end + def migrate check_target_version scope = ENV["SCOPE"] verbose_was, Migration.verbose = Migration.verbose, verbose? + Base.connection.migration_context.migrate(target_version) do |migration| scope.blank? || scope == migration.scope end + ActiveRecord::Base.clear_cache! ensure Migration.verbose = verbose_was end + def migrate_status + unless ActiveRecord::SchemaMigration.table_exists? + Kernel.abort "Schema migrations table does not exist yet." + end + + # output + puts "\ndatabase: #{ActiveRecord::Base.connection_config[:database]}\n\n" + puts "#{'Status'.center(8)} #{'Migration ID'.ljust(14)} Migration Name" + puts "-" * 50 + ActiveRecord::Base.connection.migration_context.migrations_status.each do |status, version, name| + puts "#{status.center(8)} #{version.ljust(14)} #{name}" + end + puts + 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']}`" @@ -198,8 +241,8 @@ module ActiveRecord ENV["VERSION"].to_i if ENV["VERSION"] && !ENV["VERSION"].empty? end - def charset_current(environment = env) - charset ActiveRecord::Base.configurations[environment] + def charset_current(environment = env, specification_name = spec) + charset ActiveRecord::Base.configurations.configs_for(env_name: environment, spec_name: specification_name).config end def charset(*arguments) @@ -207,8 +250,8 @@ module ActiveRecord class_for_adapter(configuration["adapter"]).new(*arguments).charset end - def collation_current(environment = env) - collation ActiveRecord::Base.configurations[environment] + def collation_current(environment = env, specification_name = spec) + collation ActiveRecord::Base.configurations.configs_for(env_name: environment, spec_name: specification_name).config end def collation(*arguments) @@ -248,6 +291,7 @@ module ActiveRecord def load_schema(configuration, format = ActiveRecord::Base.schema_format, file = nil, environment = env, spec_name = "primary") # :nodoc: file ||= dump_filename(spec_name, format) + verbose_was, Migration.verbose = Migration.verbose, verbose? && ENV["VERBOSE"] check_schema_file(file) ActiveRecord::Base.establish_connection(configuration) @@ -261,6 +305,8 @@ module ActiveRecord end ActiveRecord::InternalMetadata.create_table ActiveRecord::InternalMetadata[:environment] = environment + ensure + Migration.verbose = verbose_was end def schema_file(format = ActiveRecord::Base.schema_format) @@ -286,6 +332,16 @@ module ActiveRecord ENV["SCHEMA"] || File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, filename) end + def cache_dump_filename(namespace) + filename = if namespace == "primary" + "schema_cache.yml" + else + "#{namespace}_schema_cache.yml" + end + + ENV["SCHEMA_CACHE"] || File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, filename) + end + def load_schema_current(format = ActiveRecord::Base.schema_format, file = nil, environment = env) each_current_configuration(environment) { |configuration, spec_name, env| load_schema(configuration, format, file, env, spec_name) @@ -295,7 +351,7 @@ module ActiveRecord def check_schema_file(filename) unless File.exist?(filename) - message = %{#{filename} doesn't exist yet. Run `rails db:migrate` to create it, then try again.}.dup + message = +%{#{filename} doesn't exist yet. Run `rails db:migrate` to create it, then try again.} message << %{ If you do not intend to use a database, you should instead alter #{Rails.root}/config/application.rb to limit the frameworks that will be loaded.} if defined?(::Rails.root) Kernel.abort message end @@ -339,14 +395,15 @@ module ActiveRecord environments << "test" if environment == "development" environments.each do |env| - ActiveRecord::DatabaseConfigurations.configs_for(env) do |spec_name, configuration| - yield configuration, spec_name, env + ActiveRecord::Base.configurations.configs_for(env_name: env).each do |db_config| + yield db_config.config, db_config.spec_name, env end end end def each_local_configuration - ActiveRecord::Base.configurations.each_value do |configuration| + ActiveRecord::Base.configurations.configs_for.each do |db_config| + configuration = db_config.config next unless configuration["database"] if local_database?(configuration) diff --git a/activerecord/lib/active_record/tasks/mysql_database_tasks.rb b/activerecord/lib/active_record/tasks/mysql_database_tasks.rb index e697fa6def..1c1b29b5e1 100644 --- a/activerecord/lib/active_record/tasks/mysql_database_tasks.rb +++ b/activerecord/lib/active_record/tasks/mysql_database_tasks.rb @@ -68,9 +68,7 @@ module ActiveRecord private - def configuration - @configuration - end + attr_reader :configuration def configuration_without_database configuration.merge("database" => nil) @@ -106,7 +104,7 @@ module ActiveRecord end def run_cmd_error(cmd, args, action) - msg = "failed to execute: `#{cmd}`\n".dup + msg = +"failed to execute: `#{cmd}`\n" msg << "Please check the output above for any errors and make sure that `#{cmd}` is installed in your PATH and has proper permissions.\n\n" msg end diff --git a/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb b/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb index 647e066137..8acb11f75f 100644 --- a/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb +++ b/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb @@ -6,8 +6,8 @@ module ActiveRecord module Tasks # :nodoc: class PostgreSQLDatabaseTasks # :nodoc: DEFAULT_ENCODING = ENV["CHARSET"] || "utf8" - ON_ERROR_STOP_1 = "ON_ERROR_STOP=1".freeze - SQL_COMMENT_BEGIN = "--".freeze + ON_ERROR_STOP_1 = "ON_ERROR_STOP=1" + SQL_COMMENT_BEGIN = "--" delegate :connection, :establish_connection, :clear_active_connections!, to: ActiveRecord::Base @@ -82,7 +82,7 @@ module ActiveRecord def structure_load(filename, extra_flags) set_psql_env - args = ["-v", ON_ERROR_STOP_1, "-q", "-f", filename] + args = ["-v", ON_ERROR_STOP_1, "-q", "-X", "-f", filename] args.concat(Array(extra_flags)) if extra_flags args << configuration["database"] run_cmd("psql", args, "loading") @@ -90,9 +90,7 @@ module ActiveRecord private - def configuration - @configuration - end + attr_reader :configuration def encoding configuration["encoding"] || DEFAULT_ENCODING @@ -117,7 +115,7 @@ module ActiveRecord end def run_cmd_error(cmd, args, action) - msg = "failed to execute:\n".dup + msg = +"failed to execute:\n" msg << "#{cmd} #{args.join(' ')}\n\n" msg << "Please check the output above for any errors and make sure that `#{cmd}` is installed in your PATH and has proper permissions.\n\n" msg diff --git a/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb b/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb index dfe599c4dd..a82cea80ca 100644 --- a/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb +++ b/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb @@ -60,20 +60,14 @@ module ActiveRecord private - def configuration - @configuration - end - - def root - @root - end + attr_reader :configuration, :root def run_cmd(cmd, args, out) fail run_cmd_error(cmd, args) unless Kernel.system(cmd, *args, out: out) end def run_cmd_error(cmd, args) - msg = "failed to execute:\n".dup + msg = +"failed to execute:\n" msg << "#{cmd} #{args.join(' ')}\n\n" msg << "Please check the output above for any errors and make sure that `#{cmd}` is installed in your PATH and has proper permissions.\n\n" msg diff --git a/activerecord/lib/active_record/test_databases.rb b/activerecord/lib/active_record/test_databases.rb index 606a3b0fb5..999830ba79 100644 --- a/activerecord/lib/active_record/test_databases.rb +++ b/activerecord/lib/active_record/test_databases.rb @@ -5,32 +5,32 @@ require "active_support/testing/parallelization" module ActiveRecord module TestDatabases # :nodoc: ActiveSupport::Testing::Parallelization.after_fork_hook do |i| - create_and_migrate(i, spec_name: Rails.env) + create_and_load_schema(i, env_name: Rails.env) end - ActiveSupport::Testing::Parallelization.run_cleanup_hook do |i| - drop(i, spec_name: Rails.env) + ActiveSupport::Testing::Parallelization.run_cleanup_hook do + drop(env_name: Rails.env) end - def self.create_and_migrate(i, spec_name:) + def self.create_and_load_schema(i, env_name:) old, ENV["VERBOSE"] = ENV["VERBOSE"], "false" - connection_spec = ActiveRecord::Base.configurations[spec_name] - - connection_spec["database"] += "-#{i}" - ActiveRecord::Tasks::DatabaseTasks.create(connection_spec) - ActiveRecord::Base.establish_connection(connection_spec) - ActiveRecord::Tasks::DatabaseTasks.migrate + ActiveRecord::Base.configurations.configs_for(env_name: env_name).each do |db_config| + db_config.config["database"] += "-#{i}" + ActiveRecord::Tasks::DatabaseTasks.create(db_config.config) + ActiveRecord::Tasks::DatabaseTasks.load_schema(db_config.config, ActiveRecord::Base.schema_format, nil, env_name, db_config.spec_name) + end ensure - ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations[Rails.env]) + ActiveRecord::Base.establish_connection(Rails.env.to_sym) ENV["VERBOSE"] = old end - def self.drop(i, spec_name:) + def self.drop(env_name:) old, ENV["VERBOSE"] = ENV["VERBOSE"], "false" - connection_spec = ActiveRecord::Base.configurations[spec_name] - ActiveRecord::Tasks::DatabaseTasks.drop(connection_spec) + ActiveRecord::Base.configurations.configs_for(env_name: env_name).each do |db_config| + ActiveRecord::Tasks::DatabaseTasks.drop(db_config.config) + end ensure ENV["VERBOSE"] = old end diff --git a/activerecord/lib/active_record/test_fixtures.rb b/activerecord/lib/active_record/test_fixtures.rb new file mode 100644 index 0000000000..8c60d71669 --- /dev/null +++ b/activerecord/lib/active_record/test_fixtures.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +module ActiveRecord + module TestFixtures + extend ActiveSupport::Concern + + def before_setup # :nodoc: + setup_fixtures + super + end + + def after_teardown # :nodoc: + super + teardown_fixtures + end + + included do + class_attribute :fixture_path, instance_writer: false + class_attribute :fixture_table_names, default: [] + class_attribute :fixture_class_names, default: {} + class_attribute :use_transactional_tests, default: true + class_attribute :use_instantiated_fixtures, default: false # true, false, or :no_instances + class_attribute :pre_loaded_fixtures, default: false + class_attribute :config, default: ActiveRecord::Base + class_attribute :lock_threads, default: true + end + + module ClassMethods + # Sets the model class for a fixture when the class name cannot be inferred from the fixture name. + # + # Examples: + # + # set_fixture_class some_fixture: SomeModel, + # 'namespaced/fixture' => Another::Model + # + # The keys must be the fixture names, that coincide with the short paths to the fixture files. + def set_fixture_class(class_names = {}) + self.fixture_class_names = fixture_class_names.merge(class_names.stringify_keys) + end + + def fixtures(*fixture_set_names) + if fixture_set_names.first == :all + raise StandardError, "No fixture path found. Please set `#{self}.fixture_path`." if fixture_path.blank? + fixture_set_names = Dir["#{fixture_path}/{**,*}/*.{yml}"].uniq + fixture_set_names.map! { |f| f[(fixture_path.to_s.size + 1)..-5] } + else + fixture_set_names = fixture_set_names.flatten.map(&:to_s) + end + + self.fixture_table_names |= fixture_set_names + setup_fixture_accessors(fixture_set_names) + end + + def setup_fixture_accessors(fixture_set_names = nil) + fixture_set_names = Array(fixture_set_names || fixture_table_names) + methods = Module.new do + fixture_set_names.each do |fs_name| + fs_name = fs_name.to_s + accessor_name = fs_name.tr("/", "_").to_sym + + define_method(accessor_name) do |*fixture_names| + force_reload = fixture_names.pop if fixture_names.last == true || fixture_names.last == :reload + return_single_record = fixture_names.size == 1 + fixture_names = @loaded_fixtures[fs_name].fixtures.keys if fixture_names.empty? + + @fixture_cache[fs_name] ||= {} + + instances = fixture_names.map do |f_name| + f_name = f_name.to_s if f_name.is_a?(Symbol) + @fixture_cache[fs_name].delete(f_name) if force_reload + + if @loaded_fixtures[fs_name][f_name] + @fixture_cache[fs_name][f_name] ||= @loaded_fixtures[fs_name][f_name].find + else + raise StandardError, "No fixture named '#{f_name}' found for fixture set '#{fs_name}'" + end + end + + return_single_record ? instances.first : instances + end + private accessor_name + end + end + include methods + end + + def uses_transaction(*methods) + @uses_transaction = [] unless defined?(@uses_transaction) + @uses_transaction.concat methods.map(&:to_s) + end + + def uses_transaction?(method) + @uses_transaction = [] unless defined?(@uses_transaction) + @uses_transaction.include?(method.to_s) + end + end + + def run_in_transaction? + use_transactional_tests && + !self.class.uses_transaction?(method_name) + end + + def setup_fixtures(config = ActiveRecord::Base) + if pre_loaded_fixtures && !use_transactional_tests + raise RuntimeError, "pre_loaded_fixtures requires use_transactional_tests" + end + + @fixture_cache = {} + @fixture_connections = [] + @@already_loaded_fixtures ||= {} + @connection_subscriber = nil + + # Load fixtures once and begin transaction. + if run_in_transaction? + if @@already_loaded_fixtures[self.class] + @loaded_fixtures = @@already_loaded_fixtures[self.class] + else + @loaded_fixtures = load_fixtures(config) + @@already_loaded_fixtures[self.class] = @loaded_fixtures + end + + # Begin transactions for connections already established + @fixture_connections = enlist_fixture_connections + @fixture_connections.each do |connection| + connection.begin_transaction joinable: false, _lazy: false + connection.pool.lock_thread = true if lock_threads + end + + # When connections are established in the future, begin a transaction too + @connection_subscriber = ActiveSupport::Notifications.subscribe("!connection.active_record") do |_, _, _, _, payload| + spec_name = payload[:spec_name] if payload.key?(:spec_name) + + if spec_name + begin + connection = ActiveRecord::Base.connection_handler.retrieve_connection(spec_name) + rescue ConnectionNotEstablished + connection = nil + end + + if connection && !@fixture_connections.include?(connection) + connection.begin_transaction joinable: false, _lazy: false + connection.pool.lock_thread = true if lock_threads + @fixture_connections << connection + end + end + end + + # Load fixtures for every test. + else + ActiveRecord::FixtureSet.reset_cache + @@already_loaded_fixtures[self.class] = nil + @loaded_fixtures = load_fixtures(config) + end + + # Instantiate fixtures for every test if requested. + instantiate_fixtures if use_instantiated_fixtures + end + + def teardown_fixtures + # Rollback changes if a transaction is active. + if run_in_transaction? + ActiveSupport::Notifications.unsubscribe(@connection_subscriber) if @connection_subscriber + @fixture_connections.each do |connection| + connection.rollback_transaction if connection.transaction_open? + connection.pool.lock_thread = false + end + @fixture_connections.clear + else + ActiveRecord::FixtureSet.reset_cache + end + + ActiveRecord::Base.clear_active_connections! + end + + def enlist_fixture_connections + setup_shared_connection_pool + + ActiveRecord::Base.connection_handler.connection_pool_list.map(&:connection) + end + + private + + # Shares the writing connection pool with connections on + # other handlers. + # + # In an application with a primary and replica the test fixtures + # need to share a connection pool so that the reading connection + # can see data in the open transaction on the writing connection. + def setup_shared_connection_pool + writing_handler = ActiveRecord::Base.connection_handler + + ActiveRecord::Base.connection_handlers.values.each do |handler| + if handler != writing_handler + handler.connection_pool_list.each do |pool| + name = pool.spec.name + writing_connection = writing_handler.retrieve_connection_pool(name) + handler.send(:owner_to_pool)[name] = writing_connection + end + end + end + end + + def load_fixtures(config) + fixtures = ActiveRecord::FixtureSet.create_fixtures(fixture_path, fixture_table_names, fixture_class_names, config) + Hash[fixtures.map { |f| [f.name, f] }] + end + + def instantiate_fixtures + if pre_loaded_fixtures + raise RuntimeError, "Load fixtures before instantiating them." if ActiveRecord::FixtureSet.all_loaded_fixtures.empty? + ActiveRecord::FixtureSet.instantiate_all_loaded_fixtures(self, load_instances?) + else + raise RuntimeError, "Load fixtures before instantiating them." if @loaded_fixtures.nil? + @loaded_fixtures.each_value do |fixture_set| + ActiveRecord::FixtureSet.instantiate_fixtures(self, fixture_set, load_instances?) + end + end + end + + def load_instances? + use_instantiated_fixtures != :no_instances + end + end +end diff --git a/activerecord/lib/active_record/timestamp.rb b/activerecord/lib/active_record/timestamp.rb index 54aa7aca2c..04a1c03474 100644 --- a/activerecord/lib/active_record/timestamp.rb +++ b/activerecord/lib/active_record/timestamp.rb @@ -53,6 +53,12 @@ module ActiveRecord end module ClassMethods # :nodoc: + def touch_attributes_with_time(*names, time: nil) + attribute_names = timestamp_attributes_for_update_in_model + attribute_names |= names.map(&:to_s) + attribute_names.index_with(time || current_time_from_proper_timezone) + end + private def timestamp_attributes_for_create_in_model timestamp_attributes_for_create.select { |c| column_names.include?(c) } @@ -95,8 +101,8 @@ module ActiveRecord super end - def _update_record(*args, touch: true, **options) - if touch && should_record_timestamps? + def _update_record + if @_touch_record && should_record_timestamps? current_time = current_time_from_proper_timezone timestamp_attributes_for_update_in_model.each do |column| @@ -104,7 +110,13 @@ module ActiveRecord _write_attribute(column, current_time) end end - super(*args) + + super + end + + def create_or_update(touch: true, **) + @_touch_record = touch + super end def should_record_timestamps? @@ -127,11 +139,10 @@ module ActiveRecord self.class.send(:current_time_from_proper_timezone) end - def max_updated_column_timestamp(timestamp_names = timestamp_attributes_for_update_in_model) - timestamp_names - .map { |attr| self[attr] } + def max_updated_column_timestamp + timestamp_attributes_for_update_in_model + .map { |attr| self[attr]&.to_time } .compact - .map(&:to_time) .max end diff --git a/activerecord/lib/active_record/touch_later.rb b/activerecord/lib/active_record/touch_later.rb index f70b7c50a2..5dc88fb26c 100644 --- a/activerecord/lib/active_record/touch_later.rb +++ b/activerecord/lib/active_record/touch_later.rb @@ -2,7 +2,7 @@ module ActiveRecord # = Active Record Touch Later - module TouchLater + module TouchLater # :nodoc: extend ActiveSupport::Concern included do diff --git a/activerecord/lib/active_record/transactions.rb b/activerecord/lib/active_record/transactions.rb index 97cba5d1c7..a45d228298 100644 --- a/activerecord/lib/active_record/transactions.rb +++ b/activerecord/lib/active_record/transactions.rb @@ -234,6 +234,12 @@ module ActiveRecord set_callback(:commit, :after, *args, &block) end + # Shortcut for <tt>after_commit :hook, on: [ :create, :update ]</tt>. + def after_save_commit(*args, &block) + set_options_for_callbacks!(args, on: [ :create, :update ]) + set_callback(:commit, :after, *args, &block) + end + # Shortcut for <tt>after_commit :hook, on: :create</tt>. def after_create_commit(*args, &block) set_options_for_callbacks!(args, on: :create) @@ -306,9 +312,7 @@ module ActiveRecord end def save(*) #:nodoc: - rollback_active_record_state! do - with_transaction_returning_status { super } - end + with_transaction_returning_status { super } end def save!(*) #:nodoc: @@ -319,17 +323,6 @@ module ActiveRecord with_transaction_returning_status { super } end - # Reset id and @new_record if the transaction rolls back. - def rollback_active_record_state! - remember_transaction_record_state - yield - rescue Exception - restore_transaction_record_state - raise - ensure - clear_transaction_record_state - end - def before_committed! # :nodoc: _run_before_commit_without_transaction_enrollment_callbacks _run_before_commit_callbacks @@ -340,11 +333,13 @@ module ActiveRecord # Ensure that it is not called if the object was never persisted (failed create), # but call it after the commit of a destroyed object. def committed!(should_run_callbacks: true) #:nodoc: - if should_run_callbacks && destroyed? || persisted? + if should_run_callbacks && (destroyed? || persisted?) + @_committed_already_called = true _run_commit_without_transaction_enrollment_callbacks _run_commit_callbacks end ensure + @_committed_already_called = false force_clear_transaction_record_state end @@ -382,33 +377,33 @@ module ActiveRecord status = nil self.class.transaction do add_to_transaction - begin - status = yield - rescue ActiveRecord::Rollback - clear_transaction_record_state - status = nil - end - + status = yield raise ActiveRecord::Rollback unless status end status - ensure - if @transaction_state && @transaction_state.committed? - clear_transaction_record_state - end end private + attr_reader :_committed_already_called, :_trigger_update_callback, :_trigger_destroy_callback # Save the new record state and id of a record so it can be restored later if a transaction fails. def remember_transaction_record_state - @_start_transaction_state[:id] = id @_start_transaction_state.reverse_merge!( + id: id, new_record: @new_record, destroyed: @destroyed, frozen?: frozen?, ) @_start_transaction_state[:level] = (@_start_transaction_state[:level] || 0) + 1 + remember_new_record_before_last_commit + end + + def remember_new_record_before_last_commit + if _committed_already_called + @_new_record_before_last_commit = false + else + @_new_record_before_last_commit = @_start_transaction_state[:new_record] + end end # Clear the new record state and id of a record. @@ -440,22 +435,16 @@ module ActiveRecord end end - # Determine if a record was created or destroyed in a transaction. State should be one of :new_record or :destroyed. - def transaction_record_state(state) - @_start_transaction_state[state] - end - # Determine if a transaction included an action for :create, :update, or :destroy. Used in filtering callbacks. def transaction_include_any_action?(actions) actions.any? do |action| case action when :create - transaction_record_state(:new_record) - when :destroy - defined?(@_trigger_destroy_callback) && @_trigger_destroy_callback + persisted? && @_new_record_before_last_commit when :update - !(transaction_record_state(:new_record) || destroyed?) && - (defined?(@_trigger_update_callback) && @_trigger_update_callback) + !(@_new_record_before_last_commit || destroyed?) && _trigger_update_callback + when :destroy + _trigger_destroy_callback end end end @@ -491,7 +480,8 @@ module ActiveRecord def update_attributes_from_transaction_state(transaction_state) if transaction_state && transaction_state.finalized? - restore_transaction_record_state if transaction_state.rolledback? + restore_transaction_record_state(transaction_state.fully_rolledback?) if transaction_state.rolledback? + force_clear_transaction_record_state if transaction_state.fully_committed? clear_transaction_record_state if transaction_state.fully_completed? end end diff --git a/activerecord/lib/active_record/type.rb b/activerecord/lib/active_record/type.rb index c303186ef2..03d00006b7 100644 --- a/activerecord/lib/active_record/type.rb +++ b/activerecord/lib/active_record/type.rb @@ -48,12 +48,11 @@ module ActiveRecord private - def current_adapter_name - ActiveRecord::Base.connection.adapter_name.downcase.to_sym - end + def current_adapter_name + ActiveRecord::Base.connection.adapter_name.downcase.to_sym + end end - Helpers = ActiveModel::Type::Helpers BigInteger = ActiveModel::Type::BigInteger Binary = ActiveModel::Type::Binary Boolean = ActiveModel::Type::Boolean diff --git a/activerecord/lib/active_record/type/serialized.rb b/activerecord/lib/active_record/type/serialized.rb index e882784691..0a2f6cb9fb 100644 --- a/activerecord/lib/active_record/type/serialized.rb +++ b/activerecord/lib/active_record/type/serialized.rb @@ -51,6 +51,10 @@ module ActiveRecord end end + def force_equality?(value) + coder.respond_to?(:object_class) && value.is_a?(coder.object_class) + end + private def default_value?(value) diff --git a/activerecord/lib/active_record/validations/uniqueness.rb b/activerecord/lib/active_record/validations/uniqueness.rb index 5a1dbc8e53..2c3a2fb797 100644 --- a/activerecord/lib/active_record/validations/uniqueness.rb +++ b/activerecord/lib/active_record/validations/uniqueness.rb @@ -12,7 +12,7 @@ module ActiveRecord raise ArgumentError, "#{options[:scope]} is not supported format for :scope option. " \ "Pass a symbol or an array of symbols instead: `scope: :user_id`" end - super({ case_sensitive: true }.merge!(options)) + super @klass = options[:class] end @@ -25,7 +25,7 @@ module ActiveRecord if finder_class.primary_key relation = relation.where.not(finder_class.primary_key => record.id_in_database) else - raise UnknownPrimaryKey.new(finder_class, "Can not validate uniqueness for persisted record without primary key.") + raise UnknownPrimaryKey.new(finder_class, "Cannot validate uniqueness for persisted record without primary key.") end end relation = scope_relation(record, relation) @@ -56,33 +56,21 @@ module ActiveRecord end def build_relation(klass, attribute, value) - if reflection = klass._reflect_on_association(attribute) - attribute = reflection.foreign_key - value = value.attributes[reflection.klass.primary_key] unless value.nil? - end - - if value.nil? - return klass.unscoped.where!(attribute => value) - end - - # the attribute may be an aliased attribute - if klass.attribute_alias?(attribute) - attribute = klass.attribute_alias(attribute) + relation = klass.unscoped + comparison = relation.bind_attribute(attribute, value) do |attr, bind| + return relation.none! if bind.unboundable? + + if !options.key?(:case_sensitive) || bind.nil? + klass.connection.default_uniqueness_comparison(attr, bind, klass) + elsif options[:case_sensitive] + klass.connection.case_sensitive_comparison(attr, bind) + else + # will use SQL LOWER function before comparison, unless it detects a case insensitive collation + klass.connection.case_insensitive_comparison(attr, bind) + end end - attribute_name = attribute.to_s - value = klass.predicate_builder.build_bind_attribute(attribute_name, value) - - table = klass.arel_table - column = klass.columns_hash[attribute_name] - - comparison = if !options[:case_sensitive] - # will use SQL LOWER function before comparison, unless it detects a case insensitive collation - klass.connection.case_insensitive_comparison(table, attribute, column, value) - else - klass.connection.case_sensitive_comparison(table, attribute, column, value) - end - klass.unscoped.where!(comparison) + relation.where!(comparison) end def scope_relation(record, relation) |