module Sequel
  module Plugins
    # Sequel by default does not use proxies for associations.  The association
    # method for *_to_many associations returns an array, and the association_dataset
    # method returns a dataset.  This plugin makes the association method return a proxy
    # that will load the association and call a method on the association array if sent
    # an array method, and otherwise send the method to the association's dataset.
    # 
    # You can override which methods to forward to the dataset by passing a block to the plugin:
    #
    #   plugin :association_proxies do |opts|
    #     [:find, :where, :create].include?(opts[:method])
    #   end
    #
    # If the block returns false or nil, the method is sent to the array of associated
    # objects.  Otherwise, the method is sent to the association dataset.  Here are the entries
    # in the hash passed to the block:
    #
    # :method :: The name of the method
    # :arguments :: The arguments to the method
    # :block :: The block given to the method
    # :instance :: The model instance related to the call
    # :reflection :: The reflection for the association related to the call
    # :proxy_argument :: The argument given to the association method call
    # :proxy_block :: The block given to the association method call
    # 
    # For example, in a call like:
    #
    #   artist.albums(1){|ds| ds}.foo(2){|x| 3}
    #
    # The opts passed to the block would be:
    #
    #   {
    #     :method => :foo,
    #     :arguments => [2],
    #     :block => {|x| 3},
    #     :instance => artist,
    #     :reflection => {:name=>:albums, ...},
    #     :proxy_argument => 1,
    #     :proxy_block => {|ds| ds}
    #   }
    #
    # Usage:
    #
    #   # Use association proxies in all model subclasses (called before loading subclasses)
    #   Sequel::Model.plugin :association_proxies
    #
    #   # Use association proxies in a specific model subclass
    #   Album.plugin :association_proxies
    module AssociationProxies
      def self.configure(model, &block)
        model.instance_eval do
          @association_proxy_to_dataset = block if block
          @association_proxy_to_dataset ||= AssociationProxy::DEFAULT_PROXY_TO_DATASET
        end
      end

      # A proxy for the association.  Calling an array method will load the
      # associated objects and call the method on the associated object array.
      # Calling any other method will call that method on the association's dataset.
      class AssociationProxy < BasicObject
        array = []

        # Default proc used to determine whether to sent the method to the dataset.
        # If the array would respond to it, sends it to the array instead of the dataset.
        DEFAULT_PROXY_TO_DATASET = proc{|opts| !array.respond_to?(opts[:method])}

        # Set the association reflection to use, and whether the association should be
        # reloaded if an array method is called.
        def initialize(instance, reflection, proxy_argument, &proxy_block)
          @instance = instance
          @reflection = reflection
          @proxy_argument = proxy_argument
          @proxy_block = proxy_block
        end

        # Call the method given on the array of associated objects if the method
        # is an array method, otherwise call the method on the association's dataset.
        def method_missing(meth, *args, &block)
          v = if @instance.model.association_proxy_to_dataset.call(:method=>meth, :arguments=>args, :block=>block, :instance=>@instance, :reflection=>@reflection, :proxy_argument=>@proxy_argument, :proxy_block=>@proxy_block)
            @instance.send(@reflection.dataset_method)
          else
            @instance.send(:load_associated_objects, @reflection, @proxy_argument, &@proxy_block)
          end
          v.send(meth, *args, &block)
        end
      end

      module ClassMethods
        # Proc that accepts a method name, array of arguments, and block and
        # should return a truthy value to send the method to the dataset instead of the
        # array of associated objects.
        attr_reader :association_proxy_to_dataset

        Plugins.inherited_instance_variables(self, :@association_proxy_to_dataset=>nil)

        # Changes the association method to return a proxy instead of the associated objects
        # directly.
        def def_association_method(opts)
          opts.returns_array? ? association_module_def(opts.association_method, opts){|*r, &block| AssociationProxy.new(self, opts, r[0], &block)} : super
        end
      end
    end
  end
end
