diff options
Diffstat (limited to 'activerecord/lib')
12 files changed, 179 insertions, 94 deletions
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 84a9797aa5..0d384950fe 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -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, diff --git a/activerecord/lib/active_record/associations/preloader.rb b/activerecord/lib/active_record/associations/preloader.rb index 8997579527..c7cd87f9d4 100644 --- a/activerecord/lib/active_record/associations/preloader.rb +++ b/activerecord/lib/active_record/associations/preloader.rb @@ -143,9 +143,7 @@ module ActiveRecord def preloaders_for_reflection(reflection, records, scope) records.group_by { |record| record.association(reflection.name).klass }.map do |rhs_klass, rs| - loader = preloader_for(reflection, rs).new(rhs_klass, rs, reflection, scope) - loader.run self - loader + preloader_for(reflection, rs).new(rhs_klass, rs, reflection, scope).run end end @@ -166,10 +164,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 7048ff43b8..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 @@ -54,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? @@ -87,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 diff --git a/activerecord/lib/active_record/associations/preloader/through_association.rb b/activerecord/lib/active_record/associations/preloader/through_association.rb index db89b77629..bec1c4c94a 100644 --- a/activerecord/lib/active_record/associations/preloader/through_association.rb +++ b/activerecord/lib/active_record/associations/preloader/through_association.rb @@ -4,45 +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() - 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, 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| - record.association(source_reflection.name).target + records = through_records.flat_map do |record| + source_records_by_owner[record] end - result.compact! - result.sort_by! { |rhs| preload_index[rhs] } if scope.order_values.any? - result.uniq! if scope.distinct_value - associate_records_to_owner(owner, result) - end - - unless scope.empty_scope? - middle_records.each do |owner| - owner.association(source_reflection.name).reset if owner - end + 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 @@ -52,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 @@ -96,7 +108,7 @@ module ActiveRecord end end - scope unless scope.empty_scope? + scope end end end diff --git a/activerecord/lib/active_record/insert_all.rb b/activerecord/lib/active_record/insert_all.rb index d30aee7c00..4b02d40aa0 100644 --- a/activerecord/lib/active_record/insert_all.rb +++ b/activerecord/lib/active_record/insert_all.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ActiveRecord - class InsertAll + class InsertAll # :nodoc: attr_reader :model, :connection, :inserts, :keys attr_reader :on_duplicate, :returning, :unique_by @@ -73,15 +73,11 @@ module ActiveRecord raise ArgumentError, "#{connection.class} does not support :returning" end - unless %i{ raise skip update }.member?(on_duplicate) - raise NotImplementedError, "#{on_duplicate.inspect} is an unknown value for :on_duplicate. Valid values are :raise, :skip, and :update" - end - - if on_duplicate == :skip && !connection.supports_insert_on_duplicate_skip? + if skip_duplicates? && !connection.supports_insert_on_duplicate_skip? raise ArgumentError, "#{connection.class} does not support skipping duplicates" end - if on_duplicate == :update && !connection.supports_insert_on_duplicate_update? + if update_duplicates? && !connection.supports_insert_on_duplicate_update? raise ArgumentError, "#{connection.class} does not support upsert" end @@ -137,16 +133,16 @@ module ActiveRecord end def returning - quote_columns(insert_all.returning).join(",") if insert_all.returning + format_columns(insert_all.returning) if insert_all.returning end def conflict_target if index = insert_all.unique_by - sql = +"(#{quote_columns(index.columns).join(',')})" + sql = +"(#{format_columns(index.columns)})" sql << " WHERE #{index.where}" if index.where sql elsif update_duplicates? - "(#{quote_columns(insert_all.primary_keys).join(',')})" + "(#{format_columns(insert_all.primary_keys)})" end end @@ -158,7 +154,7 @@ module ActiveRecord attr_reader :connection, :insert_all def columns_list - quote_columns(insert_all.keys).join(",") + format_columns(insert_all.keys) end def extract_types_from_columns_on(table_name, keys:) @@ -170,6 +166,10 @@ module ActiveRecord 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 diff --git a/activerecord/lib/active_record/persistence.rb b/activerecord/lib/active_record/persistence.rb index ba03a3773a..7705cefa59 100644 --- a/activerecord/lib/active_record/persistence.rb +++ b/activerecord/lib/active_record/persistence.rb @@ -88,7 +88,7 @@ module ActiveRecord # (Postgres-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 RETURNING SQL + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL # clause entirely. # # [:unique_by] @@ -157,7 +157,7 @@ module ActiveRecord # (Postgres-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 RETURNING SQL + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL # clause entirely. # # ==== Examples @@ -205,7 +205,7 @@ module ActiveRecord # (Postgres-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 RETURNING SQL + # or <tt>returning: false</tt> to omit the underlying <tt>RETURNING</tt> SQL # clause entirely. # # [:unique_by] diff --git a/activerecord/lib/active_record/querying.rb b/activerecord/lib/active_record/querying.rb index 81ab502824..ae1501f5a1 100644 --- a/activerecord/lib/active_record/querying.rb +++ b/activerecord/lib/active_record/querying.rb @@ -13,7 +13,7 @@ module ActiveRecord :destroy_all, :delete_all, :update_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, :eager_load, :includes, :from, :lock, :readonly, :extending, :or, + :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 diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 7717852753..b0535cfff5 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -116,7 +116,7 @@ module ActiveRecord # explicitly, as #where references the tables for you. For example, this # will work correctly: # - # User.includes(:posts).where(posts: { name: 'example' }) + # User.includes(:posts).where(posts: { name: 'example' }) def includes(*args) check_if_method_has_arguments!(:includes, args) spawn.includes!(*args) @@ -160,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. diff --git a/activerecord/lib/active_record/store.rb b/activerecord/lib/active_record/store.rb index 3537e2d008..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. # @@ -49,6 +55,12 @@ module ActiveRecord # 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 @@ -127,6 +139,42 @@ module ActiveRecord 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/rails/generators/active_record/migration/templates/create_table_migration.rb.tt b/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt index 5f7201cfe1..562543f981 100644 --- a/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt +++ b/activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt @@ -6,7 +6,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi t.string :password_digest<%= attribute.inject_options %> <% elsif attribute.token? -%> t.string :<%= attribute.name %><%= attribute.inject_options %> -<% else -%> +<% elsif !attribute.virtual? -%> t.<%= attribute.type %> :<%= attribute.name %><%= attribute.inject_options %> <% end -%> <% end -%> diff --git a/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt b/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt index 481c70201b..c07380bec9 100644 --- a/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt +++ b/activerecord/lib/rails/generators/active_record/migration/templates/migration.rb.tt @@ -7,7 +7,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi <%- elsif attribute.token? -%> add_column :<%= table_name %>, :<%= attribute.name %>, :string<%= attribute.inject_options %> add_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %>, unique: true - <%- else -%> + <%- elsif !attribute.virtual? -%> add_column :<%= table_name %>, :<%= attribute.name %>, :<%= attribute.type %><%= attribute.inject_options %> <%- if attribute.has_index? -%> add_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %> @@ -21,7 +21,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi <%- attributes.each do |attribute| -%> <%- if attribute.reference? -%> t.references :<%= attribute.name %><%= attribute.inject_options %> - <%- else -%> + <%- elsif !attribute.virtual? -%> <%= '# ' unless attribute.has_index? -%>t.index <%= attribute.index_name %><%= attribute.inject_index_options %> <%- end -%> <%- end -%> @@ -37,7 +37,9 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi <%- if attribute.has_index? -%> remove_index :<%= table_name %>, :<%= attribute.index_name %><%= attribute.inject_index_options %> <%- end -%> + <%- if !attribute.virtual? %> remove_column :<%= table_name %>, :<%= attribute.name %>, :<%= attribute.type %><%= attribute.inject_options %> + <%- end -%> <%- end -%> <%- end -%> <%- end -%> diff --git a/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt b/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt index 55dc65c8ad..cdd029735a 100644 --- a/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt +++ b/activerecord/lib/rails/generators/active_record/model/templates/model.rb.tt @@ -3,6 +3,9 @@ class <%= class_name %> < <%= parent_class_name.classify %> <% attributes.select(&:reference?).each do |attribute| -%> belongs_to :<%= attribute.name %><%= ', polymorphic: true' if attribute.polymorphic? %><%= ', required: true' if attribute.required? %> <% end -%> +<% attributes.select(&:rich_text?).each do |attribute| -%> + has_rich_text :<%= attribute.name %> +<% end -%> <% attributes.select(&:token?).each do |attribute| -%> has_secure_token<% if attribute.name != "token" %> :<%= attribute.name %><% end %> <% end -%> |