lib/db_mod/statements/parameters.rb

Summary

Maintainability
A
0 mins
Test Coverage
module DbMod
  module Statements
    # Parsing and validation of query parameters
    # for prepared SQL statements
    module Parameters
      # Called when a {DbMod} dynamically defined method is called.
      # Assert that the named arguments given for the prepared statement
      # with the given name satisfy expectations. Returns a parameter array
      # as per {Parameters.parameter_array}.
      #
      # @param expected [Array<Symbol>] the parameters expected to be present
      # @param args [Array<Hash<Symbol>>] arguments given to the method being
      #   executed. The method should only be called with an options hash that
      #   contains exactly the parameter names given when the method was
      #   defined.
      # @return [Array] values to be passed to the prepared statement
      def self.valid_named_args!(expected, args)
        wrapped_hash! args

        args = args.first
        if args.size != expected.size
          fail ArgumentError, "#{args.size} args given, #{expected.size} needed"
        end

        parameter_array(expected, args)
      end

      # Called when a {DbMod} dynamically defined method is called.
      # Assert that the correct number of arguments has been provided.
      #
      # @param count [Fixnum] arity of the method being called.
      # @param args [Array] list of arguments given.
      # @raise [ArgumentError] if the wrong number of arguments is given
      def self.valid_fixed_args!(count, args)
        unless args.size == count
          fail ArgumentError, "#{args.size} args given, #{count} expected"
        end
      end

      # Called when a {DbMod} dynamically defined method is declared.
      # Parses parameters, named or numbered, from an SQL
      # statement. See the {Prepared} module documentation
      # for more. This method may modify the sql statement
      # to change named parameters to numbered parameters.
      # If the query uses numbered parameters, an integer
      # will be returned that is the arity of the statement.
      # If the query uses named parameters, an array of
      # symbols will be returned, giving the order in which
      # the named parameters should be fed into the
      # statement.
      #
      # @param sql [String] statement to prepare
      # @return [Fixnum,Array<Symbol>] description of
      #   prepared statement's parameters
      # @raise [ArgumentError] if there is any sort of problem
      #   with the parameters declared in the sql statement
      def self.parse_params!(sql)
        Parameters.valid_sql_params! sql
        numbered = sql.scan NUMBERED_PARAM
        named = sql.scan NAMED_PARAM

        if numbered.any?
          fail ArgumentError, 'mixed named and numbered params' if named.any?
          Parameters.parse_numbered_params! numbered
        else
          Parameters.parse_named_params! sql, named
        end
      end

      # Regex matching a numbered parameter
      NUMBERED_PARAM = /\$\d+/

      # Regex matching a named parameter
      NAMED_PARAM = /\$[a-z]+(?:_[a-z]+)*/

      # For validation, named or numbered parameter
      NAMED_OR_NUMBERED = /^\$(?:\d+|[a-z]+(?:_[a-z]+)*)$/

      private

      # Assert that the given parameter list is an array
      # containing a single hash of named parameter values.
      #
      # Raises +ArgumentError+ otherwise.
      #
      # @param args [Array<Hash<Symbol>>] method arguments being validated
      # @raise [ArgumentError] if the arguments passed to the method
      #   do not pass validation
      def self.wrapped_hash!(args)
        unless args.size == 1
          fail ArgumentError, "unexpected arguments: #{args.inspect}"
        end

        unless args.first.is_a? Hash
          fail ArgumentError, "invalid argument: #{args.first.inspect}"
        end
      end

      # Convert the given named parameter hash into
      # an array containing the parameter values in
      # the order required to be supplied to the
      # SQL statement being executed.
      #
      # @param expected [Array<Symbol>] the parameters expected to be present
      # @param args [Hash] given parameters
      # @return [Array] values to be passed to the prepared statement
      # @raise [ArgumentError] if any arguments are missing
      def self.parameter_array(expected, args)
        expected.map do |arg|
          fail(ArgumentError, "missing arg #{arg}") unless args.key? arg

          args[arg]
        end
      end

      # Fails if any parameters in an sql query aren't
      # in the expected format. They must either be
      # lower_case_a_to_z or digits only.
      #
      # @param sql [String] sql statement that may or may
      #   not contain parameters
      # @raise [ArgumentError] if there are any invalid
      #   parameter declarations
      def self.valid_sql_params!(sql)
        sql.scan(/\$[A-Za-z0-9_]+/) do |param|
          unless param =~ NAMED_OR_NUMBERED
            fail ArgumentError, "Invalid parameter #{param}"
          end
        end
      end

      # Validates the numbered parameters given (i.e. no gaps),
      # and returns the parameter count.
      #
      # @param params [Array<String>] '$1','$2', etc...
      # @return [Fixnum] parameter count
      # @raise [ArgumentError] if there are any problems with the
      #   given argument list
      def self.parse_numbered_params!(params)
        params.sort!
        params.uniq!
        if params.last[1..-1].to_i != params.length ||
           params.first[1..-1].to_i != 1
          fail ArgumentError, 'Invalid parameter list'
        end

        params.length
      end

      # Replaces the given list of named parameters in the
      # query string with numbered parameters, and returns
      # an array of symbols giving the order the parameters
      # should be fed into the prepared statement for execution.
      #
      # @param sql [String] the SQL statement. Will be modified.
      # @param params [Array<String>] '$one', '$two', etc...
      # @return [Array<Symbol>] unique list of named parameters
      def self.parse_named_params!(sql, params)
        unique_params = params.uniq
        params.each do |param|
          index = unique_params.index(param)
          sql[param] = "$#{index + 1}"
        end

        unique_params.map { |p| p[1..-1].to_sym }
      end
    end
  end
end