# frozen-string-literal: true
#
# The pg_hstore extension adds support for the PostgreSQL hstore type
# to Sequel.  hstore is an extension that ships with PostgreSQL, and
# the hstore type stores an arbitrary key-value table, where the keys
# are strings and the values are strings or NULL.
#
# This extension integrates with Sequel's native postgres and jdbc/postgresql
# adapters, so that when hstore fields are retrieved, they are parsed and returned
# as instances of Sequel::Postgres::HStore.  HStore is
# a DelegateClass of Hash, so it mostly acts like a hash, but not
# completely (is_a?(Hash) is false).  If you want the actual hash,
# you can call HStore#to_hash.  This is done so that Sequel does not
# treat a HStore like a Hash by default, which would cause issues.
#
# In addition to the parsers, this extension comes with literalizers
# for HStore using the standard Sequel literalization callbacks, so
# they work with on all adapters.
#
# To turn an existing Hash into an HStore, use Sequel.hstore:
#
#   Sequel.hstore(hash)
#
# If you have loaded the {core_extensions extension}[rdoc-ref:doc/core_extensions.rdoc],
# or you have loaded the core_refinements extension
# and have activated refinements for the file, you can also use Hash#hstore:
# 
#   hash.hstore
#
# Since the hstore type only supports strings, non string keys and
# values are converted to strings
#
#   Sequel.hstore(foo: 1).to_hash # {'foo'=>'1'}
#   v = Sequel.hstore({})
#   v[:foo] = 1
#   v # {'foo'=>'1'}
#
# However, to make life easier, lookups by key are converted to
# strings (even when accessing the underlying hash directly):
#
#   Sequel.hstore('foo'=>'bar')[:foo] # 'bar'
#   Sequel.hstore('foo'=>'bar').to_hash[:foo] # 'bar'
# 
# HStore instances mostly just delegate to the underlying hash
# instance, so Hash methods that modify the receiver or returned
# modified copies of the receiver may not do string conversion.
# The following methods will handle string conversion, and more
# can be added later if desired:
#
# * \[\]
# * \[\]=
# * assoc
# * delete
# * fetch
# * has_key?
# * has_value?
# * include?
# * key
# * key?
# * member?
# * merge
# * merge!
# * rassoc
# * replace
# * store
# * update
# * value?
#
# If you want to insert a hash into an hstore database column:
#
#   DB[:table].insert(column: Sequel.hstore('foo'=>'bar'))
#
# To use this extension, first load it into your Sequel::Database instance:
#
#   DB.extension :pg_hstore
#
# This extension integrates with the pg_array extension.  If you plan
# to use arrays of hstore types, load the pg_array extension before the
# pg_hstore extension:
#
#   DB.extension :pg_array, :pg_hstore
#
# See the {schema modification guide}[rdoc-ref:doc/schema_modification.rdoc]
# for details on using hstore columns in CREATE/ALTER TABLE statements.
#
# This extension requires the delegate and strscan libraries.
#
# Related module: Sequel::Postgres::HStore

require 'delegate'
require 'strscan'

module Sequel
  module Postgres
    class HStore < DelegateClass(Hash)
      include Sequel::SQL::AliasMethods

      # Parser for PostgreSQL hstore output format.
      class Parser < StringScanner
        # Parse the output format that PostgreSQL uses for hstore
        # columns.  Note that this does not attempt to parse all
        # input formats that PostgreSQL will accept.  For instance,
        # it expects all keys and non-NULL values to be quoted.
        #
        # Return the resulting hash of objects.  This can be called
        # multiple times, it will cache the parsed hash on the first
        # call and use it for subsequent calls.
        def parse
          return @result if @result
          hash = {}
          while !eos?
            skip(/"/)
            k = parse_quoted
            skip(/"\s*=>\s*/)
            if skip(/"/)
              v = parse_quoted
              skip(/"/)
            else
              scan(/NULL/)
              v = nil
            end
            skip(/,\s*/)
            hash[k] = v
          end
          @result = hash
        end
          
        private

        # Parse and unescape a quoted key/value.
        def parse_quoted
          scan(/(\\"|[^"])*/).gsub(/\\(.)/, '\1')
        end
      end

      module DatabaseMethods
        def self.extended(db)
          db.instance_exec do
            add_named_conversion_proc(:hstore){|v| HStore.parse(v)}
            @schema_type_classes[:hstore] = HStore
          end
        end

        # Handle hstores in bound variables
        def bound_variable_arg(arg, conn)
          case arg
          when HStore
            arg.unquoted_literal
          when Hash
            HStore.new(arg).unquoted_literal
          else
            super
          end
        end

        private

        # Recognize the hstore database type.
        def schema_column_type(db_type)
          db_type == 'hstore' ? :hstore : super
        end

        # Set the :callable_default value if the default value is recognized as an empty hstore.
        def schema_post_process(_)
          super.each do |a|
            h = a[1]
            if h[:type] == :hstore && h[:default] =~ /\A''::hstore\z/
              h[:callable_default] = lambda{HStore.new({})}
            end
          end
        end

        # Typecast value correctly to HStore.  If already an
        # HStore instance, return as is.  If a hash, return
        # an HStore version of it.  If a string, assume it is
        # in PostgreSQL output format and parse it using the
        # parser.
        def typecast_value_hstore(value)
          case value
          when HStore
            value
          when Hash
            HStore.new(value)
          else
            raise Sequel::InvalidValue, "invalid value for hstore: #{value.inspect}"
          end
        end
      end

      # Default proc used for all underlying HStore hashes, so that even
      # if you grab the underlying hash, it will still convert non-string
      # keys to strings during lookup.
      DEFAULT_PROC = lambda{|h, k| h[k.to_s] unless k.is_a?(String)}

      # Undef marshal_{dump,load} methods in the delegate class,
      # so that ruby uses the old style _dump/_load methods defined
      # in the delegate class, instead of the marshal_{dump,load} methods
      # in the Hash class.
      undef_method :marshal_load
      undef_method :marshal_dump

      # Use custom marshal loading, since underlying hash uses a default proc.
      def self._load(args)
        new(Hash[Marshal.load(args)])
      end

      # Parse the given string into an HStore, assuming the str is in PostgreSQL
      # hstore output format.
      def self.parse(str)
        new(Parser.new(str).parse)
      end

      # Override methods that accept key argument to convert to string.
      %w'[] delete has_key? include? key? member? assoc'.each do |m|
        class_eval("def #{m}(k) super(k.to_s) end", __FILE__, __LINE__)
      end

      # Override methods that accept value argument to convert to string unless nil.
      %w'has_value? value? key rassoc'.each do |m|
        class_eval("def #{m}(v) super(convert_value(v)) end", __FILE__, __LINE__)
      end

      # Override methods that accept key and value arguments to convert to string appropriately.
      %w'[]= store'.each do |m|
        class_eval("def #{m}(k, v) super(k.to_s, convert_value(v)) end", __FILE__, __LINE__)
      end

      # Override methods that take hashes to convert the hashes to using strings for keys and
      # values before using them.
      %w'initialize merge! update replace'.each do |m|
        class_eval("def #{m}(h, &block) super(convert_hash(h), &block) end", __FILE__, __LINE__)
      end

      # Use custom marshal dumping, since underlying hash uses a default proc.
      def _dump(*)
        Marshal.dump(to_a)
      end

      # Override to force the key argument to a string.
      def fetch(key, *args, &block)
        super(key.to_s, *args, &block)
      end

      # Convert the input hash to string keys and values before merging,
      # and return a new HStore instance with the merged hash.
      def merge(hash, &block)
        self.class.new(super(convert_hash(hash), &block))
      end

      # Return the underlying hash used by this HStore instance.
      alias to_hash __getobj__

      # Append a literalize version of the hstore to the sql.
      def sql_literal_append(ds, sql)
        ds.literal_append(sql, unquoted_literal)
        sql << '::hstore'
      end

      # Return a string containing the unquoted, unstring-escaped
      # literal version of the hstore.  Separated out for use by
      # the bound argument code.
      def unquoted_literal
        str = String.new
        comma = false
        commas = ","
        quote = '"'
        kv_sep = "=>"
        null = "NULL"
        each do |k, v|
          str << commas if comma
          str << quote << escape_value(k) << quote
          str << kv_sep
          if v.nil?
            str << null
          else
            str << quote << escape_value(v) << quote
          end
          comma = true
        end
        str
      end

      # Allow automatic parameterization.
      def sequel_auto_param_type(ds)
        "::hstore"
      end

      private

      # Return a new hash based on the input hash with string
      # keys and string or nil values.
      def convert_hash(h)
        hash = Hash.new(&DEFAULT_PROC)
        h.each{|k,v| hash[k.to_s] = convert_value(v)}
        hash
      end

      # Return value v as a string unless it is already nil.
      def convert_value(v)
        v.to_s unless v.nil?
      end

      # Escape key/value strings when literalizing to
      # correctly handle backslash and quote characters.
      def escape_value(k)
        k.to_s.gsub(/("|\\)/, '\\\\\1')
      end
    end
  end

  module SQL::Builders
    # Return a Postgres::HStore proxy for the given hash.
    def hstore(v)
      case v
      when Postgres::HStore
        v
      when Hash
        Postgres::HStore.new(v)
      else
        # May not be defined unless the pg_hstore_ops extension is used
        hstore_op(v)
      end
    end
  end

  Database.register_extension(:pg_hstore, Postgres::HStore::DatabaseMethods)
end

# :nocov:
if Sequel.core_extensions?
  class Hash
    # Create a new HStore using the receiver as the input
    # hash.  Note that the HStore created will not use the
    # receiver as the backing store, since it has to
    # modify the hash.  To get the new backing store, use:
    #
    #   hash.hstore.to_hash
    def hstore
      Sequel::Postgres::HStore.new(self)
    end
  end
end

if defined?(Sequel::CoreRefinements)
  module Sequel::CoreRefinements
    refine Hash do
      def hstore
        Sequel::Postgres::HStore.new(self)
      end
    end
  end
end
# :nocov:
