# frozen-string-literal: true

module Sequel
  module Plugins
    # The finder plugin adds Model.finder for defining optimized finder methods.
    # There are two ways to use this.  The recommended way is to pass a symbol
    # that represents a model class method that returns a dataset:
    #
    #   def Artist.by_name(name)
    #     where(name: name)
    #   end
    #
    #   Artist.finder :by_name
    #
    # This creates an optimized first_by_name method, which you can call normally:
    #
    #   Artist.first_by_name("Joe")
    #
    # The alternative way to use this to pass your own block:
    #
    #   Artist.finder(name: :first_by_name){|pl, ds| ds.where(name: pl.arg).limit(1)}
    #
    # Additionally, there is a Model.prepared_finder method.  This works similarly
    # to Model.finder, but uses a prepared statement.  This limits the types of
    # arguments that will be accepted, but can perform better in the database.
    # 
    # Usage:
    #
    #   # Make all model subclasses support Model.finder
    #   # (called before loading subclasses)
    #   Sequel::Model.plugin :finder
    #
    #   # Make the Album class support Model.finder
    #   Album.plugin :finder
    module Finder
      FINDER_TYPES = [:first, :all, :each, :get].freeze

      def self.apply(mod)
        mod.instance_exec do
          @finders ||= {}
          @finder_loaders ||= {}
        end
      end

      module ClassMethods
        # Create an optimized finder method using a dataset placeholder literalizer.
        # This pre-computes the SQL to use for the query, except for given arguments.
        #
        # There are two ways to use this.  The recommended way is to pass a symbol
        # that represents a model class method that returns a dataset:
        #
        #   def Artist.by_name(name)
        #     where(name: name)
        #   end
        #
        #   Artist.finder :by_name
        #
        # This creates an optimized first_by_name method, which you can call normally:
        #
        #   Artist.first_by_name("Joe")
        #
        # The alternative way to use this to pass your own block:
        #
        #   Artist.finder(name: :first_by_name){|pl, ds| ds.where(name: pl.arg).limit(1)}
        #
        # Note that if you pass your own block, you are responsible for manually setting
        # limits if necessary (as shown above).
        #
        # Options:
        # :arity :: When using a symbol method name, this specifies the arity of the method.
        #           This should be used if if the method accepts an arbitrary number of arguments,
        #           or the method has default argument values.  Note that if the method is defined
        #           as a dataset method, the class method Sequel creates accepts an arbitrary number
        #           of arguments, so you should use this option in that case.  If you want to handle
        #           multiple possible arities, you need to call the finder method multiple times with
        #           unique :arity and :name methods each time.
        # :name :: The name of the method to create.  This must be given if you pass a block.
        #          If you use a symbol, this defaults to the symbol prefixed by the type.
        # :mod :: The module in which to create the finder method.  Defaults to the singleton
        #         class of the model.
        # :type :: The type of query to run.  Can be :first, :each, :all, or :get, defaults to
        #          :first.
        #
        # Caveats:
        #
        # This doesn't handle all possible cases.  For example, if you have a method such as:
        #
        #   def Artist.by_name(name)
        #     name ? where(name: name) : exclude(name: nil)
        #   end
        #
        # Then calling a finder without an argument will not work as you expect.
        #
        #   Artist.finder :by_name
        #   Artist.by_name(nil).first
        #   # WHERE (name IS NOT NULL)
        #   Artist.first_by_name(nil)
        #   # WHERE (name IS NULL)
        #
        # See Dataset::PlaceholderLiteralizer for additional caveats.
        def finder(meth=OPTS, opts=OPTS, &block)
          if block
            raise Error, "cannot pass both a method name argument and a block of Model.finder" unless meth.is_a?(Hash)
            raise Error, "cannot pass two option hashes to Model.finder" unless opts.equal?(OPTS)
            opts = meth
            raise Error, "must provide method name via :name option when passing block to Model.finder" unless meth_name = opts[:name]
          end

          type = opts.fetch(:type, :first)
          unless prepare = opts[:prepare]
            raise Error, ":type option to Model.finder must be :first, :all, :each, or :get" unless FINDER_TYPES.include?(type)
          end
          limit1 = type == :first || type == :get
          meth_name ||= opts[:name] || :"#{type}_#{meth}"

          argn = lambda do |model|
            if arity = opts[:arity]
              arity
            else
              method = block || model.method(meth)
              (method.arity < 0 ? method.arity.abs - 1 : method.arity)
            end
          end

          loader_proc = if prepare
            proc do |model|
              args = prepare_method_args('$a', argn.call(model))
              ds = if block
                model.instance_exec(*args, &block)
              else
                model.public_send(meth, *args)
              end
              ds = ds.limit(1) if limit1
              model_name = model.name
              if model_name.to_s.empty?
                model_name = model.object_id
              else
                model_name = model_name.gsub(/\W/, '_')
              end
              ds.prepare(type, :"#{model_name}_#{meth_name}")
            end
          else
            proc do |model|
              n = argn.call(model)
              block ||= lambda do |pl, model2|
                args = (0...n).map{pl.arg}
                ds = model2.public_send(meth, *args)
                ds = ds.limit(1) if limit1
                ds
              end

              Sequel::Dataset::PlaceholderLiteralizer.loader(model, &block) 
            end
          end

          @finder_loaders[meth_name] = loader_proc
          mod = opts[:mod] || singleton_class
          if prepare
            def_prepare_method(mod, meth_name)
          else
            def_finder_method(mod, meth_name, type)
          end
        end

        def freeze
          @finder_loaders.freeze
          @finder_loaders.each_key{|k| finder_for(k)} if @dataset
          @finders.freeze
          super
        end

        # Similar to finder, but uses a prepared statement instead of a placeholder
        # literalizer. This makes the SQL used static (cannot vary per call), but
        # allows binding argument values instead of literalizing them into the SQL
        # query string.
        #
        # If a block is used with this method, it is instance_execed by the model,
        # and should accept the desired number of placeholder arguments.
        #
        # The options are the same as the options for finder, with the following
        # exception:
        # :type :: Specifies the type of prepared statement to create
        def prepared_finder(meth=OPTS, opts=OPTS, &block)
          if block
            raise Error, "cannot pass both a method name argument and a block of Model.finder" unless meth.is_a?(Hash)
            meth = meth.merge(:prepare=>true)
          else
            opts = opts.merge(:prepare=>true)
          end
          finder(meth, opts, &block)
        end

        Plugins.inherited_instance_variables(self, :@finders=>:dup, :@finder_loaders=>:dup)

        private

        # Define a finder method in the given module with the given method name that
        # load rows using the finder with the given name.
        def def_finder_method(mod, meth, type)
          mod.send(:define_method, meth){|*args, &block| finder_for(meth).public_send(type, *args, &block)}
        end

        # Define a prepared_finder method in the given module that will call the associated prepared
        # statement.
        def def_prepare_method(mod, meth)
          mod.send(:define_method, meth){|*args, &block| finder_for(meth).call(prepare_method_arg_hash(args), &block)}
        end

        # Find the finder to use for the give method.  If a finder has not been loaded
        # for the method, load the finder and set correctly in the finders hash, then
        # return the finder.
        def finder_for(meth)
          unless finder = (frozen? ? @finders[meth] : Sequel.synchronize{@finders[meth]})
            finder_loader = @finder_loaders.fetch(meth)
            finder = finder_loader.call(self)
            Sequel.synchronize{@finders[meth] = finder}
          end
          finder
        end

        # An hash of prepared argument values for the given arguments, with keys
        # starting at a.  Used by the methods created by prepared_finder.
        def prepare_method_arg_hash(args)
          h = {}
          prepare_method_args('a', args.length).zip(args).each{|k, v| h[k] = v}
          h
        end

        # An array of prepared statement argument names, of length n and starting with base.
        def prepare_method_args(base, n)
          (0...n).map do
            s = base.to_sym
            base = base.next
            s
          end
        end

        # Clear any finders when reseting the instance dataset
        def reset_instance_dataset
          Sequel.synchronize{@finders.clear} if @finders && !@finders.frozen?
          super
        end
      end
    end
  end
end
