From 52f8e4b9dae6137fcd95793dffc26ddff80b623e Mon Sep 17 00:00:00 2001 From: Jon Leighton Date: Mon, 21 Feb 2011 01:09:41 +0000 Subject: Use proper objects to do the work to build the associations (adding methods, callbacks etc) rather than calling a whole bunch of methods with rather long names. --- .../associations/builder/association.rb | 53 ++++++++++++++ .../associations/builder/belongs_to.rb | 83 ++++++++++++++++++++++ .../associations/builder/collection_association.rb | 75 +++++++++++++++++++ .../builder/has_and_belongs_to_many.rb | 63 ++++++++++++++++ .../active_record/associations/builder/has_many.rb | 63 ++++++++++++++++ .../active_record/associations/builder/has_one.rb | 61 ++++++++++++++++ .../associations/builder/singular_association.rb | 32 +++++++++ .../associations/collection_association.rb | 39 ++++++++++ .../associations/has_many_association.rb | 1 + .../associations/has_one_association.rb | 19 +++-- .../associations/singular_association.rb | 16 +++++ 11 files changed, 501 insertions(+), 4 deletions(-) create mode 100644 activerecord/lib/active_record/associations/builder/association.rb create mode 100644 activerecord/lib/active_record/associations/builder/belongs_to.rb create mode 100644 activerecord/lib/active_record/associations/builder/collection_association.rb create mode 100644 activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb create mode 100644 activerecord/lib/active_record/associations/builder/has_many.rb create mode 100644 activerecord/lib/active_record/associations/builder/has_one.rb create mode 100644 activerecord/lib/active_record/associations/builder/singular_association.rb (limited to 'activerecord/lib/active_record/associations') diff --git a/activerecord/lib/active_record/associations/builder/association.rb b/activerecord/lib/active_record/associations/builder/association.rb new file mode 100644 index 0000000000..96fca97440 --- /dev/null +++ b/activerecord/lib/active_record/associations/builder/association.rb @@ -0,0 +1,53 @@ +module ActiveRecord::Associations::Builder + class Association #:nodoc: + class_attribute :valid_options + self.valid_options = [:class_name, :foreign_key, :select, :conditions, :include, :extend, :readonly, :validate] + + # Set by subclasses + class_attribute :macro + + attr_reader :model, :name, :options, :reflection + + def self.build(model, name, options) + new(model, name, options).build + end + + def initialize(model, name, options) + @model, @name, @options = model, name, options + end + + def build + validate_options + reflection = model.create_reflection(self.class.macro, name, options, model) + define_accessors + reflection + end + + private + + def validate_options + options.assert_valid_keys(self.class.valid_options) + end + + def define_accessors + define_readers + define_writers + end + + def define_readers + name = self.name + + model.redefine_method(name) do |*params| + association(name).reader(*params) + end + end + + def define_writers + name = self.name + + model.redefine_method("#{name}=") do |value| + association(name).writer(value) + end + end + end +end diff --git a/activerecord/lib/active_record/associations/builder/belongs_to.rb b/activerecord/lib/active_record/associations/builder/belongs_to.rb new file mode 100644 index 0000000000..964e7fddc8 --- /dev/null +++ b/activerecord/lib/active_record/associations/builder/belongs_to.rb @@ -0,0 +1,83 @@ +module ActiveRecord::Associations::Builder + class BelongsTo < SingularAssociation #:nodoc: + self.macro = :belongs_to + + self.valid_options += [:foreign_type, :polymorphic, :touch] + + def constructable? + !options[:polymorphic] + end + + def build + reflection = super + add_counter_cache_callbacks(reflection) if options[:counter_cache] + add_touch_callbacks(reflection) if options[:touch] + configure_dependency + reflection + end + + private + + def add_counter_cache_callbacks(reflection) + cache_column = reflection.counter_cache_column + name = self.name + + method_name = "belongs_to_counter_cache_after_create_for_#{name}" + model.redefine_method(method_name) do + record = send(name) + record.class.increment_counter(cache_column, record.id) unless record.nil? + end + model.after_create(method_name) + + method_name = "belongs_to_counter_cache_before_destroy_for_#{name}" + model.redefine_method(method_name) do + record = send(name) + record.class.decrement_counter(cache_column, record.id) unless record.nil? + end + model.before_destroy(method_name) + + model.send(:module_eval, + "#{reflection.class_name}.send(:attr_readonly,\"#{cache_column}\".intern) if defined?(#{reflection.class_name}) && #{reflection.class_name}.respond_to?(:attr_readonly)", __FILE__, __LINE__ + ) + end + + def add_touch_callbacks(reflection) + name = self.name + method_name = "belongs_to_touch_after_save_or_destroy_for_#{name}" + touch = options[:touch] + + model.redefine_method(method_name) do + record = send(name) + + unless record.nil? + if touch == true + record.touch + else + record.touch(touch) + end + end + end + + model.after_save(method_name) + model.after_touch(method_name) + model.after_destroy(method_name) + end + + def configure_dependency + if options[:dependent] + unless [:destroy, :delete].include?(options[:dependent]) + raise ArgumentError, "The :dependent option expects either :destroy or :delete (#{options[:dependent].inspect})" + end + + method_name = "belongs_to_dependent_#{options[:dependent]}_for_#{name}" + model.send(:class_eval, <<-eoruby, __FILE__, __LINE__ + 1) + def #{method_name} + association = #{name} + association.#{options[:dependent]} if association + end + eoruby + model.after_destroy method_name + end + end + end +end diff --git a/activerecord/lib/active_record/associations/builder/collection_association.rb b/activerecord/lib/active_record/associations/builder/collection_association.rb new file mode 100644 index 0000000000..f62209a226 --- /dev/null +++ b/activerecord/lib/active_record/associations/builder/collection_association.rb @@ -0,0 +1,75 @@ +module ActiveRecord::Associations::Builder + class CollectionAssociation < Association #:nodoc: + CALLBACKS = [:before_add, :after_add, :before_remove, :after_remove] + + self.valid_options += [ + :table_name, :order, :group, :having, :limit, :offset, :uniq, :finder_sql, + :counter_sql, :before_add, :after_add, :before_remove, :after_remove + ] + + attr_reader :block_extension + + def self.build(model, name, options, &extension) + new(model, name, options, &extension).build + end + + def initialize(model, name, options, &extension) + super(model, name, options) + @block_extension = extension + end + + def build + wrap_block_extension + reflection = super + CALLBACKS.each { |callback_name| define_callback(callback_name) } + reflection + end + + def writable? + true + end + + private + + def wrap_block_extension + options[:extend] = Array.wrap(options[:extend]) + + if block_extension + silence_warnings do + model.parent.const_set(extension_module_name, Module.new(&block_extension)) + end + options[:extend].push("#{model.parent}::#{extension_module_name}".constantize) + end + end + + def extension_module_name + @extension_module_name ||= "#{model.to_s.demodulize}#{name.to_s.camelize}AssociationExtension" + end + + def define_callback(callback_name) + full_callback_name = "#{callback_name}_for_#{name}" + + # TODO : why do i need method_defined? I think its because of the inheritance chain + model.class_attribute full_callback_name.to_sym unless model.method_defined?(full_callback_name) + model.send("#{full_callback_name}=", Array.wrap(options[callback_name.to_sym])) + end + + def define_readers + super + + name = self.name + model.redefine_method("#{name.to_s.singularize}_ids") do + association(name).ids_reader + end + end + + def define_writers + super + + name = self.name + model.redefine_method("#{name.to_s.singularize}_ids=") do |ids| + association(name).ids_writer(ids) + end + end + 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 new file mode 100644 index 0000000000..e40b32826a --- /dev/null +++ b/activerecord/lib/active_record/associations/builder/has_and_belongs_to_many.rb @@ -0,0 +1,63 @@ +module ActiveRecord::Associations::Builder + class HasAndBelongsToMany < CollectionAssociation #:nodoc: + self.macro = :has_and_belongs_to_many + + self.valid_options += [:join_table, :association_foreign_key, :delete_sql, :insert_sql] + + def build + reflection = super + check_validity(reflection) + redefine_destroy + reflection + end + + private + + def redefine_destroy + # Don't use a before_destroy callback since users' before_destroy + # callbacks will be executed after the association is wiped out. + name = self.name + model.send(:include, Module.new { + class_eval <<-RUBY, __FILE__, __LINE__ + 1 + def destroy # def destroy + super # super + #{name}.clear # posts.clear + end # end + RUBY + }) + end + + # TODO: These checks should probably be moved into the Reflection, and we should not be + # redefining the options[:join_table] value - instead we should define a + # reflection.join_table method. + def check_validity(reflection) + if reflection.association_foreign_key == reflection.foreign_key + raise ActiveRecord::HasAndBelongsToManyAssociationForeignKeyNeeded.new(reflection) + end + + reflection.options[:join_table] ||= join_table_name( + model.send(:undecorated_table_name, model.to_s), + model.send(:undecorated_table_name, reflection.class_name) + ) + + if model.connection.supports_primary_key? && (model.connection.primary_key(reflection.options[:join_table]) rescue false) + raise ActiveRecord::HasAndBelongsToManyAssociationWithPrimaryKeyError.new(reflection) + end + end + + # Generates a join table name from two provided table names. + # The names in the join table names end up in lexicographic order. + # + # join_table_name("members", "clubs") # => "clubs_members" + # join_table_name("members", "special_clubs") # => "members_special_clubs" + def join_table_name(first_table_name, second_table_name) + if first_table_name < second_table_name + join_table = "#{first_table_name}_#{second_table_name}" + else + join_table = "#{second_table_name}_#{first_table_name}" + end + + model.table_name_prefix + join_table + model.table_name_suffix + end + end +end diff --git a/activerecord/lib/active_record/associations/builder/has_many.rb b/activerecord/lib/active_record/associations/builder/has_many.rb new file mode 100644 index 0000000000..77bb66228d --- /dev/null +++ b/activerecord/lib/active_record/associations/builder/has_many.rb @@ -0,0 +1,63 @@ +module ActiveRecord::Associations::Builder + class HasMany < CollectionAssociation #:nodoc: + self.macro = :has_many + + self.valid_options += [:primary_key, :dependent, :as, :through, :source, :source_type, :inverse_of] + + def build + reflection = super + configure_dependency + reflection + end + + private + + def configure_dependency + if options[:dependent] + unless [:destroy, :delete_all, :nullify, :restrict].include?(options[:dependent]) + raise ArgumentError, "The :dependent option expects either :destroy, :delete_all, " \ + ":nullify or :restrict (#{options[:dependent].inspect})" + end + + send("define_#{options[:dependent]}_dependency_method") + model.before_destroy dependency_method_name + end + end + + def define_destroy_dependency_method + name = self.name + model.send(:define_method, dependency_method_name) do + send(name).each do |o| + # No point in executing the counter update since we're going to destroy the parent anyway + counter_method = ('belongs_to_counter_cache_before_destroy_for_' + self.class.name.downcase).to_sym + if o.respond_to?(counter_method) + class << o + self + end.send(:define_method, counter_method, Proc.new {}) + end + end + + send(name).delete_all + end + end + + def define_delete_all_dependency_method + name = self.name + model.send(:define_method, dependency_method_name) do + send(name).delete_all + end + end + alias :define_nullify_dependency_method :define_delete_all_dependency_method + + def define_restrict_dependency_method + name = self.name + model.send(:define_method, dependency_method_name) do + raise ActiveRecord::DeleteRestrictionError.new(name) unless send(name).empty? + end + end + + def dependency_method_name + "has_many_dependent_for_#{name}" + end + end +end diff --git a/activerecord/lib/active_record/associations/builder/has_one.rb b/activerecord/lib/active_record/associations/builder/has_one.rb new file mode 100644 index 0000000000..07ba5d088e --- /dev/null +++ b/activerecord/lib/active_record/associations/builder/has_one.rb @@ -0,0 +1,61 @@ +module ActiveRecord::Associations::Builder + class HasOne < SingularAssociation #:nodoc: + self.macro = :has_one + + self.valid_options += [:order, :as] + + class_attribute :through_options + self.through_options = [:through, :source, :source_type] + + def constructable? + !options[:through] + end + + def build + reflection = super + configure_dependency unless options[:through] + reflection + end + + private + + def validate_options + valid_options = self.class.valid_options + valid_options += self.class.through_options if options[:through] + options.assert_valid_keys(valid_options) + end + + def configure_dependency + if options[:dependent] + unless [:destroy, :delete, :nullify, :restrict].include?(options[:dependent]) + raise ArgumentError, "The :dependent option expects either :destroy, :delete, " \ + ":nullify or :restrict (#{options[:dependent].inspect})" + end + + send("define_#{options[:dependent]}_dependency_method") + model.before_destroy dependency_method_name + end + end + + def dependency_method_name + "has_one_dependent_#{options[:dependent]}_for_#{name}" + end + + def define_destroy_dependency_method + model.send(:class_eval, <<-eoruby, __FILE__, __LINE__ + 1) + def #{dependency_method_name} + association(#{name.to_sym.inspect}).delete + end + eoruby + end + alias :define_delete_dependency_method :define_destroy_dependency_method + alias :define_nullify_dependency_method :define_destroy_dependency_method + + def define_restrict_dependency_method + name = self.name + model.redefine_method(dependency_method_name) do + raise ActiveRecord::DeleteRestrictionError.new(name) unless send(name).nil? + end + end + end +end diff --git a/activerecord/lib/active_record/associations/builder/singular_association.rb b/activerecord/lib/active_record/associations/builder/singular_association.rb new file mode 100644 index 0000000000..06a414b874 --- /dev/null +++ b/activerecord/lib/active_record/associations/builder/singular_association.rb @@ -0,0 +1,32 @@ +module ActiveRecord::Associations::Builder + class SingularAssociation < Association #:nodoc: + self.valid_options += [:remote, :dependent, :counter_cache, :primary_key, :inverse_of] + + def constructable? + true + end + + def define_accessors + super + define_constructors if constructable? + end + + private + + def define_constructors + name = self.name + + model.redefine_method("build_#{name}") do |*params| + association(name).build(*params) + end + + model.redefine_method("create_#{name}") do |*params| + association(name).create(*params) + end + + model.redefine_method("create_#{name}!") do |*params| + association(name).create!(*params) + end + end + end +end diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb index ba98542b67..f3761bd2c7 100644 --- a/activerecord/lib/active_record/associations/collection_association.rb +++ b/activerecord/lib/active_record/associations/collection_association.rb @@ -32,6 +32,45 @@ module ActiveRecord @proxy = CollectionProxy.new(self) end + # Implements the reader method, e.g. foo.items for Foo.has_many :items + def reader(force_reload = false) + if force_reload + klass.uncached { reload } + elsif stale_target? + reload + end + + proxy + end + + # Implements the writer method, e.g. foo.items= for Foo.has_many :items + def writer(records) + replace(records) + end + + # Implements the ids reader method, e.g. foo.item_ids for Foo.has_many :items + def ids_reader + if loaded? || options[:finder_sql] + load_target.map do |record| + record.send(reflection.association_primary_key) + end + else + column = "#{reflection.quoted_table_name}.#{reflection.association_primary_key}" + + scoped.select(column).except(:includes).map! do |record| + record.send(reflection.association_primary_key) + end + end + end + + # Implements the ids writer method, e.g. foo.item_ids= for Foo.has_many :items + def ids_writer(ids) + pk_column = reflection.primary_key_column + ids = Array.wrap(ids).reject { |id| id.blank? } + ids.map! { |i| pk_column.type_cast(i) } + replace(klass.find(ids).index_by { |r| r.id }.values_at(*ids)) + end + def reset @loaded = false @target = [] diff --git a/activerecord/lib/active_record/associations/has_many_association.rb b/activerecord/lib/active_record/associations/has_many_association.rb index 5ae81649c5..cebf3e477a 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -6,6 +6,7 @@ module ActiveRecord # If the association has a :through option further specialization # is provided by its child HasManyThroughAssociation. class HasManyAssociation < CollectionAssociation #:nodoc: + def insert_record(record, validate = true) set_owner_attributes(record) record.save(:validate => validate) diff --git a/activerecord/lib/active_record/associations/has_one_association.rb b/activerecord/lib/active_record/associations/has_one_association.rb index 47d0042519..e13f97125f 100644 --- a/activerecord/lib/active_record/associations/has_one_association.rb +++ b/activerecord/lib/active_record/associations/has_one_association.rb @@ -26,11 +26,22 @@ module ActiveRecord self.target = record end - protected - - def association_scope - super.order(options[:order]) + def delete(method = options[:dependent]) + if load_target + case method + when :delete + target.delete + when :destroy + target.destroy + when :nullify + target.update_attribute(reflection.foreign_key, nil) + end end + end + + def association_scope + super.order(options[:order]) + end private diff --git a/activerecord/lib/active_record/associations/singular_association.rb b/activerecord/lib/active_record/associations/singular_association.rb index e75fdaf36f..0d8e45adb5 100644 --- a/activerecord/lib/active_record/associations/singular_association.rb +++ b/activerecord/lib/active_record/associations/singular_association.rb @@ -1,6 +1,22 @@ module ActiveRecord module Associations class SingularAssociation < Association #:nodoc: + # Implements the reader method, e.g. foo.bar for Foo.has_one :bar + def reader(force_reload = false) + if force_reload + klass.uncached { reload } + elsif !loaded? || stale_target? + reload + end + + target + end + + # Implements the writer method, e.g. foo.items= for Foo.has_many :items + def writer(record) + replace(record) + end + def create(attributes = {}) new_record(:create, attributes) end -- cgit v1.2.3