# frozen-string-literal: true
#
# The pg_row extension adds support for Sequel to handle
# PostgreSQL's row-valued/composite types.
#
# This extension integrates with Sequel's native postgres and jdbc/postgresql adapters, so
# that when composite fields are retrieved, they are parsed and returned
# as instances of Sequel::Postgres::PGRow::(HashRow|ArrayRow), or
# optionally a custom type.  HashRow and ArrayRow are DelegateClasses of
# Hash and Array, so they mostly act like a hash or array, but not
# completely (is_a?(Hash) and is_a?(Array) are false).  If you want the
# actual hash for a HashRow, call HashRow#to_hash, and if you want the
# actual array for an ArrayRow, call ArrayRow#to_a.  This is done so
# that Sequel does not treat a values like an Array or Hash by default,
# which would cause issues.
#
# In addition to the parsers, this extension comes with literalizers
# for HashRow and ArrayRow using the standard Sequel literalization callbacks, so
# they work with on all adapters.
#
# To use this extension, first load it into the Database instance:
#
#   DB.extension :pg_row
#
# If you plan to use arrays of composite types, make sure you load the
# pg_array extension first:
# 
#   DB.extension :pg_array, :pg_row
#
# You can create an anonymous row type by calling the Sequel.pg_row with
# an array:
#
#   Sequel.pg_row(array)
#
# 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 Array#pg_row:
#
#   array.pg_row
#
# However, in most cases you are going to want something beyond anonymous
# row types.  This extension allows you to register row types on a per
# database basis, using Database#register_row_type:
#
#   DB.register_row_type(:foo)
#
# When you register the row type, Sequel will query the PostgreSQL
# system tables to find the related metadata, and will setup
# a custom HashRow subclass for that type.  This includes looking up
# conversion procs for each column in the type, so that when the composite
# type is returned from the database, the members of the type have
# the correct type.  Additionally, if the composite type also has an
# array form, Sequel registers an array type for the composite type,
# so that array columns of the composite type are converted correctly.
#
# You can then create values of that type by using Database#row_type:
#
#   DB.row_type(:address, ['123 Sesame St.', 'Some City', '12345'])
#
# Let's say table address has columns street, city, and zip.  This would return
# something similar to:
#
#   {:street=>'123 Sesame St.', :city=>'Some City', :zip=>'12345'}
#
# You can also use a hash:
#
#   DB.row_type(:address, street: '123 Sesame St.', city: 'Some City', zip: '12345')
#
# So if you have a person table that has an address column, here's how you
# could insert into the column:
#
#   DB[:table].insert(address: DB.row_type(:address, street: '123 Sesame St.', city: 'Some City', zip: '12345'))
#
# Note that registering row types without providing an explicit :converter option
# creates anonymous classes.  This results in ruby being unable to Marshal such
# objects.  You can work around this by assigning the anonymous class to a constant.
# To get a list of such anonymous classes, you can use the following code:
#
#   DB.conversion_procs.select{|k,v| v.is_a?(Sequel::Postgres::PGRow::Parser) && \
#     v.converter && (v.converter.name.nil? || v.converter.name == '') }.map{|k,v| v}
# 
# See the {schema modification guide}[rdoc-ref:doc/schema_modification.rdoc]
# for details on using row type columns in CREATE/ALTER TABLE statements.
#
# This extension requires both the strscan and delegate libraries.
#
# Related module: Sequel::Postgres::PGRow

require 'delegate'
require 'strscan'

module Sequel
  module Postgres
    module PGRow
      # Class for row-valued/composite types that are treated as arrays. By default,
      # this is only used for generic PostgreSQL record types, as registered
      # types use HashRow by default.
      class ArrayRow < DelegateClass(Array)
        include Sequel::SQL::AliasMethods

        class << self
          # The database type for this class.  May be nil if this class
          # done not have a specific database type.
          attr_accessor :db_type

          # Alias new to call, so that the class itself can be used
          # directly as a converter.
          alias call new
        end

        # Create a subclass associated with a specific database type.
        # This is done so that instances of this subclass are
        # automatically casted to the database type when literalizing.
        def self.subclass(db_type)
          Class.new(self) do
            @db_type = db_type
          end
        end

        # Sets the database type associated with this instance.  This is
        # used to override the class's default database type.
        attr_writer :db_type

        # Return the instance's database type, or the class's database
        # type if the instance has not overridden it.
        def db_type
          @db_type || self.class.db_type
        end

        # Append SQL fragment related to this object to the sql.
        def sql_literal_append(ds, sql)
          sql << 'ROW'
          ds.literal_append(sql, to_a)
          if db_type
            sql << '::'
            ds.quote_schema_table_append(sql, db_type)
          end
        end

        # Allow automatic parameterization if all values support it.
        def sequel_auto_param_type(ds)
          if db_type && all?{|v| nil == v || ds.send(:auto_param_type, v)}
            s = String.new << "::"
            ds.quote_schema_table_append(s, db_type)
            s
          end
        end
      end

      # Class for row-valued/composite types that are treated as hashes.
      # Types registered via Database#register_row_type will use this
      # class by default.
      class HashRow < DelegateClass(Hash)
        include Sequel::SQL::AliasMethods

        class << self
          # The columns associated with this class.
          attr_accessor :columns

          # The database type for this class.  May be nil if this class
          # done not have a specific database type.
          attr_accessor :db_type

          # Alias new to call, so that the class itself can be used
          # directly as a converter.
          alias call new
        end

        # Create a new subclass of this class with the given database
        # type and columns.
        def self.subclass(db_type, columns)
          Class.new(self) do
            @db_type = db_type
            @columns = columns
          end
        end

        # Return the underlying hash for this delegate object.
        alias to_hash __getobj__

        # Sets the columns associated with this instance.  This is
        # used to override the class's default columns.
        attr_writer :columns 

        # Sets the database type associated with this instance.  This is
        # used to override the class's default database type.
        attr_writer :db_type

        # Return the instance's columns, or the class's columns
        # if the instance has not overridden it.
        def columns
          @columns || self.class.columns
        end

        # Return the instance's database type, or the class's columns
        # if the instance has not overridden it.
        def db_type
          @db_type || self.class.db_type
        end

        # Check that the HashRow has valid columns.  This should be used
        # before all attempts to literalize the object, since literalization
        # depends on the columns to get the column order.
        def check_columns!
          if columns.nil? || columns.empty?
            raise Error, 'cannot literalize HashRow without columns'
          end
        end

        # Append SQL fragment related to this object to the sql.
        def sql_literal_append(ds, sql)
          check_columns!
          sql << 'ROW'
          ds.literal_append(sql, values_at(*columns))
          if db_type
            sql << '::'
            ds.quote_schema_table_append(sql, db_type)
          end
        end

        # Allow automatic parameterization if all values support it.
        def sequel_auto_param_type(ds)
          if db_type && all?{|_,v| nil == v || ds.send(:auto_param_type, v)}
            s = String.new << "::"
            ds.quote_schema_table_append(s, db_type)
            s
          end
        end
      end

      ROW_TYPE_CLASSES = [HashRow, ArrayRow].freeze

      # This parser-like class splits the PostgreSQL
      # row-valued/composite type output string format
      # into an array of strings.  Note this class makes
      # no attempt to handle all input formats that PostgreSQL
      # will accept, it only handles the output format that
      # PostgreSQL uses.
      class Splitter < StringScanner
        # Split the stored string into an array of strings, handling
        # the different types of quoting.
        def parse
          values = []
          skip(/\(/)
          if skip(/\)/)
            values << nil
          else
            # :nocov:
            until eos?
            # :nocov:
              if skip(/"/)
                values << scan(/(\\.|""|[^"])*/).gsub(/\\(.)|"(")/, '\1\2')
                skip(/"[,)]/)
              else
                v = scan(/[^,)]*/)
                values << (v unless v.empty?)
                skip(/[,)]/)
              end
            end
          end
          values
        end
      end

      # The Parser is responsible for taking the input string
      # from PostgreSQL, and returning an appropriate ruby
      # object that the type represents, such as an ArrayRow or
      # HashRow.
      class Parser
        # The columns for the parser, if any.  If the parser has
        # no columns, it will treat the input as an array.  If
        # it has columns, it will treat the input as a hash.
        # If present, should be an array of strings.
        attr_reader :columns

        # Converters for each member in the composite type.  If
        # not present, no conversion will be done, so values will
        # remain strings.  If present, should be an array of
        # callable objects.
        attr_reader :column_converters

        # The OIDs for each member in the composite type.  Not
        # currently used, but made available for user code.
        attr_reader :column_oids

        # A converter for the object as a whole.  Used to wrap
        # the returned array/hash in another object, such as an
        # ArrayRow or HashRow.  If present, should be callable.
        attr_reader :converter

        # The oid for the composite type itself.
        attr_reader :oid

        # A callable object used for typecasting the object. This
        # is similar to the converter, but it is called by the
        # typecasting code, which has different assumptions than
        # the converter.  For instance, the converter should be
        # called with all of the member values already typecast,
        # but the typecaster may not be.
        attr_reader :typecaster

        # Sets each of the parser's attributes, using options with
        # the same name (e.g. :columns sets the columns attribute).
        def initialize(h=OPTS)
          @columns = h[:columns]
          @column_converters = h[:column_converters]
          @column_oids = h[:column_oids]
          @converter = h[:converter]
          @typecaster = h[:typecaster]
          @oid = h[:oid]
        end

        # Convert the PostgreSQL composite type input format into
        # an appropriate ruby object.
        def call(s)
          convert(convert_format(convert_columns(Splitter.new(s).parse)))
        end

        # Typecast the given object to the appropriate type using the
        # typecaster.  Note that this does not conversion for the members
        # of the composite type, since those conversion expect strings and
        # strings may not be provided.  
        def typecast(obj)
          case obj 
          when Array
            _typecast(convert_format(obj))
          when Hash
            unless @columns
              raise Error, 'PGRow::Parser without columns cannot typecast from a hash'
            end
            _typecast(obj)
          else
            raise Error, 'PGRow::Parser can only typecast arrays and hashes'
          end
        end

        private

        # If the parser has a typecaster, call it with
        # the object, otherwise return the object as is.
        def _typecast(obj)
          if t = @typecaster
            t.call(obj)
          else
            obj
          end
        end

        # If the parser has column converters, map the
        # array of strings input to a array of appropriate
        # ruby objects, one for each converter.
        def convert_columns(arr)
          if ccs = @column_converters
            arr.zip(ccs).map{|v, pr| (v && pr) ? pr.call(v) : v}
          else
            arr 
          end
        end

        # If the parser has columns, return a hash assuming
        # that the array is ordered by the columns.
        def convert_format(arr)
          if cs = @columns
            h = {}
            arr.zip(cs).each{|v, c| h[c] = v}
            h
          else
            arr
          end
        end

        # If the parser has a converter, call it with the object,
        # otherwise return the object as is.
        def convert(obj)
          if c = @converter
            c.call(obj)
          else
            obj
          end
        end
      end

      module DatabaseMethods
        # A hash mapping row type keys (usually symbols), to option
        # hashes.  At the least, the values will contain the :parser
        # option for the Parser instance that the type will use.
        attr_reader :row_types

        # Do some setup for the data structures the module uses.
        def self.extended(db)
          db.instance_exec do
            @row_types = {}
            @row_schema_types = {}
            extend(@row_type_method_module = Module.new)
            add_conversion_proc(2249, PGRow::Parser.new(:converter=>PGRow::ArrayRow))
            if respond_to?(:register_array_type)
              register_array_type('record', :oid=>2287, :scalar_oid=>2249)
            end
          end
        end

        # Handle ArrayRow and HashRow values in bound variables.
        def bound_variable_arg(arg, conn)
          case arg
          when ArrayRow
            "(#{arg.map{|v| bound_variable_array(v) if v}.join(',')})"
          when HashRow
            arg.check_columns!
            "(#{arg.values_at(*arg.columns).map{|v| bound_variable_array(v) if v}.join(',')})"
          else
            super
          end
        end

        # Freeze the row types and row schema types to prevent adding new ones.
        def freeze
          @row_types.freeze
          @row_schema_types.freeze
          @row_type_method_module.freeze
          super
        end

        # Register a new row type for the Database instance. db_type should be the type
        # symbol.  This parses the PostgreSQL system tables to get information the
        # composite type, and by default has the type return instances of a subclass
        # of HashRow.
        #
        # The following options are supported:
        #
        # :converter :: Use a custom converter for the parser.
        # :typecaster :: Use a custom typecaster for the parser.
        def register_row_type(db_type, opts=OPTS)
          procs = @conversion_procs
          rel_oid = nil
          array_oid = nil
          parser_opts = {}

          # Try to handle schema-qualified types.
          type_schema, type_name = schema_and_table(db_type)
          schema_type_string = type_name.to_s

          # Get basic oid information for the composite type.
          ds = from(:pg_type).
            select{[pg_type[:oid], :typrelid, :typarray]}.
            where([[:typtype, 'c'], [:typname, type_name.to_s]])
          if type_schema
            ds = ds.join(:pg_namespace, [[:oid, :typnamespace], [:nspname, type_schema.to_s]])
            schema_type_symbol = :"pg_row_#{type_schema}__#{type_name}" 
          else
            schema_type_symbol = :"pg_row_#{type_name}"
          end
          unless row = ds.first
            raise Error, "row type #{db_type.inspect} not found in database"
          end
          # Manually cast to integer using to_i, because adapter may not cast oid type
          # correctly (e.g. swift)
          parser_opts[:oid], rel_oid, array_oid = row.values_at(:oid, :typrelid, :typarray).map(&:to_i)

          # Get column names and oids for each of the members of the composite type.
          res = from(:pg_attribute).
            join(:pg_type, :oid=>:atttypid).
            where(:attrelid=>rel_oid).
            where{attnum > 0}.
            exclude(:attisdropped).
            order(:attnum).
            select_map{[:attname, Sequel.case({0=>:atttypid}, pg_type[:typbasetype], pg_type[:typbasetype]).as(:atttypid)]}
          if res.empty?
            raise Error, "no columns for row type #{db_type.inspect} in database"
          end
          parser_opts[:columns] = res.map{|r| r[0].to_sym}
          parser_opts[:column_oids] = res.map{|r| r[1].to_i}

          # Using the conversion_procs, lookup converters for each member of the composite type
          parser_opts[:column_converters] = parser_opts[:column_oids].map do |oid|
            procs[oid]
          end

          # Setup the converter and typecaster
          parser_opts[:converter] = opts.fetch(:converter){HashRow.subclass(db_type, parser_opts[:columns])}
          parser_opts[:typecaster] = opts.fetch(:typecaster, parser_opts[:converter])

          parser = Parser.new(parser_opts)
          add_conversion_proc(parser.oid, parser)

          if respond_to?(:register_array_type) && array_oid && array_oid > 0
            array_type_name = if type_schema
              "#{type_schema}.#{type_name}"
            else
              type_name
            end
            register_array_type(array_type_name, :oid=>array_oid, :converter=>parser, :scalar_typecast=>schema_type_symbol)
          end

          @row_types[literal(db_type)] = opts.merge(:parser=>parser, :type=>db_type)
          @row_schema_types[schema_type_string] = schema_type_symbol 
          @schema_type_classes[schema_type_symbol] = ROW_TYPE_CLASSES
          @row_type_method_module.class_eval do
            meth = :"typecast_value_#{schema_type_symbol}"
            define_method(meth) do |v|
              row_type(db_type, v)
            end
            private meth
            alias_method(meth, meth)
          end

          nil
        end

        # Handle typecasting of the given object to the given database type.
        # In general, the given database type should already be registered,
        # but if obj is an array, this will handled unregistered types.
        def row_type(db_type, obj)
          (type_hash = @row_types[literal(db_type)]) &&
            (parser = type_hash[:parser])

          case obj
          when ArrayRow, HashRow
            obj
          when Array
            if parser
              parser.typecast(obj)
            else
              obj = ArrayRow.new(obj)
              obj.db_type = db_type
              obj
            end
          when Hash
            if parser 
              parser.typecast(obj)
            else
              raise InvalidValue, "Database#row_type requires the #{db_type.inspect} type have a registered parser and typecaster when called with a hash"
            end
          else
            raise InvalidValue, "cannot convert #{obj.inspect} to row type #{db_type.inspect}"
          end
        end

        private

        # Make the column type detection handle registered row types.
        def schema_composite_type(db_type)
          @row_schema_types[db_type] || super
        end
      end
    end
  end

  module SQL::Builders
    # Wraps the expr array in an anonymous Postgres::PGRow::ArrayRow instance.
    def pg_row(expr)
      case expr
      when Array
        Postgres::PGRow::ArrayRow.new(expr)
      else
        # Will only work if pg_row_ops extension is loaded
        pg_row_op(expr)
      end
    end
  end

  Database.register_extension(:pg_row, Postgres::PGRow::DatabaseMethods)
end

# :nocov:
if Sequel.core_extensions?
  class Array
    # Wraps the receiver in an anonymous Sequel::Postgres::PGRow::ArrayRow instance.
    def pg_row
      Sequel::Postgres::PGRow::ArrayRow.new(self)
    end
  end
end

if defined?(Sequel::CoreRefinements)
  module Sequel::CoreRefinements
    refine Array do
      def pg_row
        Sequel::Postgres::PGRow::ArrayRow.new(self)
      end
    end
  end
end
# :nocov:
