# frozen-string-literal: true

module Sequel
  extension :pg_array, :pg_array_ops

  module Plugins
    # This plugin allows you to create associations where the foreign keys
    # are stored in a PostgreSQL array column in one of the tables.  The
    # model with the table containing the array column has a
    # pg_array_to_many association to the associated model, and the
    # model with the table containing the primary key referenced by
    # elements in the array column has a many_to_pg_array association
    # to the associated model.
    #
    #   # Database schema:
    #   #   tags                albums
    #   #   :id (int4) <--\    :id
    #   #   :name          \-- :tag_ids (int4[])
    #   #                      :name
    #
    #   class Album
    #     plugin :pg_array_associations
    #     pg_array_to_many :tags
    #   end
    #   class Tag
    #     plugin :pg_array_associations
    #     many_to_pg_array :albums
    #   end
    #
    # These association types work similarly to Sequel's other association
    # types, so you can use them as you would any other association. Unlike
    # other associations, they do not support composite keys.
    #
    # One thing that is different is that the modification methods for
    # pg_array_to_many associations do not affect the database, since they
    # operate purely on the receiver.  For example:
    #
    #   album = Album[1]
    #   album.add_tag(Tag[2])
    #
    # does not save the album.  This allows you to call add_tag repeatedly
    # and the save after to combine all changes into a single query.  Note
    # that the many_to_pg_array association modification methods do save, so:
    #
    #   tag = Tag[2]
    #   tag.add_album(Album[1])
    #
    # will save the changes to the album.
    #
    # They support some additional options specific to this plugin:
    #
    # :array_type :: This overrides the type of the array.  By default, the type
    #                is determined by looking at the db_schema for the model, and if that fails,
    #                it defaults to :integer.
    # :raise_on_save_failure :: Do not raise exceptions for hook or validation failures when saving associated
    #                           objects in the add/remove methods (return nil instead).
    # :save_after_modify :: For pg_array_to_many associations, this makes the
    #                       the modification methods save the current object,
    #                       so they operate more similarly to the one_to_many
    #                       and many_to_many association modification methods.
    # :uniq :: Similar to many_to_many associations, this can be used to
    #          make sure the returned associated object array has uniq values.
    #
    # Note that until PostgreSQL gains the ability to enforce foreign key
    # constraints in array columns, this plugin is not recommended for
    # production use unless you plan on emulating referential integrity
    # constraints via triggers.
    #
    # This plugin should work on all supported PostgreSQL versions, except
    # the remove_all modification method for many_to_pg_array associations, which
    # requires the array_remove method added in PostgreSQL 9.3.
    #
    # This plugin requires that the underlying database have the pg_array
    # extension loaded.
    module PgArrayAssociations
      # The AssociationReflection subclass for many_to_pg_array associations.
      class ManyToPgArrayAssociationReflection < Sequel::Model::Associations::AssociationReflection
        Sequel.synchronize{Sequel::Model::Associations::ASSOCIATION_TYPES[:many_to_pg_array] = self}

        def array_type
          cached_fetch(:array_type) do
            if (sch = associated_class.db_schema) && (s = sch[self[:key]]) && (t = s[:db_type])
              t.sub(/\[\]\z/, '').freeze
            else
              :integer
            end
          end
        end

        # The array column in the associated model containing foreign keys to
        # the current model.
        def associated_object_keys
          [self[:key]]
        end

        # many_to_pg_array associations can have associated objects as long as they have
        # a primary key.
        def can_have_associated_objects?(obj)
          obj.get_column_value(self[:primary_key])
        end

        # Assume that the key in the associated table uses a version of the current
        # model's name suffixed with _ids.
        def default_key
          :"#{underscore(demodulize(self[:model].name))}_ids"
        end
        
        # Always use the ruby eager_graph limit strategy if association is limited.
        def eager_graph_limit_strategy(_)
          :ruby if self[:limit]
        end

        # Always use the ruby eager limit strategy
        def eager_limit_strategy
          cached_fetch(:_eager_limit_strategy) do
            :ruby if self[:limit]
          end
        end

        # Don't use a filter by associations limit strategy
        def filter_by_associations_limit_strategy
          nil
        end

        FINALIZE_SETTINGS = superclass::FINALIZE_SETTINGS.merge(
          :array_type=>:array_type
        ).freeze
        def finalize_settings
          FINALIZE_SETTINGS
        end

        # Handle silent failure of add/remove methods if raise_on_save_failure is false.
        def handle_silent_modification_failure?
          self[:raise_on_save_failure] == false
        end

        # The hash key to use for the eager loading predicate (left side of IN (1, 2, 3))
        def predicate_key
          cached_fetch(:predicate_key){qualify_assoc(self[:key_column])}
        end
    
        # The column in the current table that the keys in the array column in the
        # associated table reference.
        def primary_key
          self[:primary_key]
        end

        # Destroying the associated object automatically removes the association,
        # since the association is stored in the associated object.
        def remove_before_destroy?
          false
        end
    
        private
    
        # The predicate condition to use for the eager_loader.
        def eager_loading_predicate_condition(keys)
          Sequel.pg_array_op(predicate_key).overlaps(Sequel.pg_array(keys, array_type))
        end

        def filter_by_associations_add_conditions_dataset_filter(ds)
          key = qualify(associated_class.table_name, self[:key])
          ds.cross_join(Sequel.function(:unnest, key).as(:_smtopgaa_, [:_smtopgaa_key_])).exclude(key=>nil).select(:_smtopgaa_key_)
        end
        
        def filter_by_associations_conditions_key
          qualify(self[:model].table_name, primary_key)
        end

        # Only consider an association as a reciprocal if it has matching keys
        # and primary keys.
        def reciprocal_association?(assoc_reflect)
          super && self[:key] == assoc_reflect[:key] && primary_key == assoc_reflect.primary_key
        end

        def reciprocal_type
          :pg_array_to_many
        end

        def use_placeholder_loader?
          false
        end
      end

      # The AssociationReflection subclass for pg_array_to_many associations.
      class PgArrayToManyAssociationReflection < Sequel::Model::Associations::AssociationReflection
        Sequel.synchronize{Sequel::Model::Associations::ASSOCIATION_TYPES[:pg_array_to_many] = self}

        def array_type
          cached_fetch(:array_type) do
            if (sch = self[:model].db_schema) && (s = sch[self[:key]]) && (t = s[:db_type])
              t.sub(/\[\]\z/, '').freeze
            else
              :integer
            end
          end
        end

        # An array containing the primary key for the associated model.
        def associated_object_keys
          Array(primary_key)
        end

        # pg_array_to_many associations can only have associated objects if
        # the array field is not nil or empty.
        def can_have_associated_objects?(obj)
          v = obj.get_column_value(self[:key])
          v && !v.empty?
        end

        # pg_array_to_many associations do not need a primary key.
        def dataset_need_primary_key?
          false
        end

        # Use a default key name of *_ids, for similarity to other association types
        # that use *_id for single keys.
        def default_key
          :"#{singularize(self[:name])}_ids"
        end

        # Always use the ruby eager_graph limit strategy if association is limited.
        def eager_graph_limit_strategy(_)
          :ruby if self[:limit]
        end

        # Always use the ruby eager limit strategy
        def eager_limit_strategy
          cached_fetch(:_eager_limit_strategy) do
            :ruby if self[:limit]
          end
        end

        # Don't use a filter by associations limit strategy
        def filter_by_associations_limit_strategy
          nil
        end

        FINALIZE_SETTINGS = superclass::FINALIZE_SETTINGS.merge(
          :array_type=>:array_type,
          :primary_key=>:primary_key,
          :primary_key_method=>:primary_key_method
        ).freeze
        def finalize_settings
          FINALIZE_SETTINGS
        end

        # Handle silent failure of add/remove methods if raise_on_save_failure is false
        # and save_after_modify is true.
        def handle_silent_modification_failure?
          self[:raise_on_save_failure] == false && self[:save_after_modify]
        end

        # A qualified version of the associated primary key.
        def predicate_key
          cached_fetch(:predicate_key){qualify_assoc(primary_key)}
        end
    
        # The primary key of the associated model.
        def primary_key
          cached_fetch(:primary_key){associated_class.primary_key || raise(Error, "no primary key specified for #{associated_class.inspect}")}
        end

        # The method to call to get value of the primary key of the associated model.
        def primary_key_method
          cached_fetch(:primary_key_method){primary_key}
        end

        def filter_by_associations_conditions_expression(obj)
          ds = filter_by_associations_conditions_dataset.where(filter_by_associations_conditions_subquery_conditions(obj))
          Sequel.function(:coalesce, Sequel.pg_array(filter_by_associations_conditions_key).overlaps(ds), Sequel::SQL::Constants::FALSE)
        end

        private
    
        def filter_by_associations_add_conditions_dataset_filter(ds)
          pk = qualify(associated_class.table_name, primary_key)
          ds.select{array_agg(pk)}.exclude(pk=>nil)
        end
        
        def filter_by_associations_conditions_key
          qualify(self[:model].table_name, self[:key])
        end

        # Only consider an association as a reciprocal if it has matching keys
        # and primary keys.
        def reciprocal_association?(assoc_reflect)
          super && self[:key] == assoc_reflect[:key] && primary_key == assoc_reflect.primary_key
        end

        def reciprocal_type
          :many_to_pg_array
        end

        def use_placeholder_loader?
          false
        end
      end

      # Add the pg_array extension to the database
      def self.apply(model)
        model.db.extension(:pg_array)
      end

      module ClassMethods
        # Create a many_to_pg_array association, for the case where the associated
        # table contains the array with foreign keys pointing to the current table.
        # See associate for options.
        def many_to_pg_array(name, opts=OPTS, &block)
          associate(:many_to_pg_array, name, opts, &block)
        end

        # Create a pg_array_to_many association, for the case where the current
        # table contains the array with foreign keys pointing to the associated table.
        # See associate for options.
        def pg_array_to_many(name, opts=OPTS, &block)
          associate(:pg_array_to_many, name, opts, &block)
        end

        private

        # Setup the many_to_pg_array-specific datasets, eager loaders, and modification methods.
        def def_many_to_pg_array(opts)
          name = opts[:name]
          model = self
          pk = opts[:eager_loader_key] = opts[:primary_key] ||= model.primary_key
          raise(Error, "no primary key specified for #{inspect}") unless pk
          opts[:key] = opts.default_key unless opts.has_key?(:key)
          key = opts[:key]
          key_column = opts[:key_column] ||= opts[:key]
          if opts[:uniq]
            opts[:after_load] ||= []
            opts[:after_load].unshift(:array_uniq!)
          end
          opts[:dataset] ||= lambda do
            opts.associated_dataset.where(Sequel.pg_array_op(opts.predicate_key).contains(Sequel.pg_array([get_column_value(pk)], opts.array_type)))
          end
          opts[:eager_loader] ||= proc do |eo|
            id_map = eo[:id_map]
            eo = Hash[eo]
            eo[:loader] = false

            eager_load_results(opts, eo) do |assoc_record|
              if pks = assoc_record.get_column_value(key)
                pks.each do |pkv|
                  id_map[pkv].each do |object| 
                    object.associations[name].push(assoc_record)
                  end
                end
              end
            end
          end

          join_type = opts[:graph_join_type]
          select = opts[:graph_select]
          opts[:cartesian_product_number] ||= 1

          if opts.include?(:graph_only_conditions)
            conditions = opts[:graph_only_conditions]
            graph_block = opts[:graph_block]
          else
            conditions = opts[:graph_conditions]
            conditions = nil if conditions.empty?
            graph_block = proc do |j, lj, js|
              Sequel.pg_array_op(Sequel.deep_qualify(j, key_column)).contains([Sequel.deep_qualify(lj, opts.primary_key)])
            end

            if orig_graph_block = opts[:graph_block]
              pg_array_graph_block = graph_block
              graph_block = proc do |j, lj, js|
                Sequel.&(orig_graph_block.call(j,lj,js), pg_array_graph_block.call(j, lj, js))
              end
            end
          end

          opts[:eager_grapher] ||= proc do |eo|
            ds = eo[:self]
            ds = ds.graph(eager_graph_dataset(opts, eo), conditions, eo.merge(:select=>select, :join_type=>eo[:join_type]||join_type, :qualify=>:deep), &graph_block)
            ds
          end

          return if opts[:read_only]

          save_opts = {:validate=>opts[:validate]}
          save_opts[:raise_on_failure] = opts[:raise_on_save_failure] != false

          opts[:adder] ||= proc do |o|
            if array = o.get_column_value(key)
              array << get_column_value(pk)
            else
              o.set_column_value("#{key}=", Sequel.pg_array([get_column_value(pk)], opts.array_type))
            end
            o.save(save_opts)
          end
  
          opts[:remover] ||= proc do |o|
            if (array = o.get_column_value(key)) && !array.empty?
              array.delete(get_column_value(pk))
              o.save(save_opts)
            end
          end

          opts[:clearer] ||= proc do
            pk_value = get_column_value(pk)
            db_type = opts.array_type
            opts.associated_dataset.where(Sequel.pg_array_op(key).contains(Sequel.pg_array([pk_value], db_type))).update(key=>Sequel.function(:array_remove, key, Sequel.cast(pk_value, db_type)))
          end
        end

        # Setup the pg_array_to_many-specific datasets, eager loaders, and modification methods.
        def def_pg_array_to_many(opts)
          name = opts[:name]
          opts[:key] = opts.default_key unless opts.has_key?(:key)
          key = opts[:key]
          key_column = opts[:key_column] ||= key
          opts[:eager_loader_key] = nil
          if opts[:uniq]
            opts[:after_load] ||= []
            opts[:after_load].unshift(:array_uniq!)
          end
          opts[:dataset] ||= lambda do
            opts.associated_dataset.where(opts.predicate_key=>get_column_value(key).to_a)
          end
          opts[:eager_loader] ||= proc do |eo|
            rows = eo[:rows]
            id_map = {}
            pkm = opts.primary_key_method

            Sequel.synchronize_with(eo[:mutex]) do
              rows.each do |object|
                if associated_pks = object.get_column_value(key)
                  associated_pks.each do |apk|
                    (id_map[apk] ||= []) << object
                  end
                end
              end
            end

            eo = Hash[eo]
            eo[:id_map] = id_map
            eager_load_results(opts, eo) do |assoc_record|
              if objects = id_map[assoc_record.get_column_value(pkm)]
                objects.each do |object| 
                  object.associations[name].push(assoc_record)
                end
              end
            end
          end

          join_type = opts[:graph_join_type]
          select = opts[:graph_select]
          opts[:cartesian_product_number] ||= 1

          if opts.include?(:graph_only_conditions)
            conditions = opts[:graph_only_conditions]
            graph_block = opts[:graph_block]
          else
            conditions = opts[:graph_conditions]
            conditions = nil if conditions.empty?
            graph_block = proc do |j, lj, js|
              Sequel.pg_array_op(Sequel.deep_qualify(lj, key_column)).contains([Sequel.deep_qualify(j, opts.primary_key)])
            end

            if orig_graph_block = opts[:graph_block]
              pg_array_graph_block = graph_block
              graph_block = proc do |j, lj, js|
                Sequel.&(orig_graph_block.call(j,lj,js), pg_array_graph_block.call(j, lj, js))
              end
            end
          end

          opts[:eager_grapher] ||= proc do |eo|
            ds = eo[:self]
            ds = ds.graph(eager_graph_dataset(opts, eo), conditions, eo.merge(:select=>select, :join_type=>eo[:join_type]||join_type, :qualify=>:deep), &graph_block)
            ds
          end

          return if opts[:read_only]

          save_opts = {:validate=>opts[:validate]}
          save_opts[:raise_on_failure] = opts[:raise_on_save_failure] != false

          if opts[:save_after_modify]
            save_after_modify = proc do |obj|
              obj.save(save_opts)
            end
          end

          opts[:adder] ||= proc do |o|
            opk = o.get_column_value(opts.primary_key) 
            if array = get_column_value(key)
              modified!(key)
              array << opk
            else
              set_column_value("#{key}=", Sequel.pg_array([opk], opts.array_type))
            end
            save_after_modify.call(self) if save_after_modify
          end
  
          opts[:remover] ||= proc do |o|
            if (array = get_column_value(key)) && !array.empty?
              modified!(key)
              array.delete(o.get_column_value(opts.primary_key))
              save_after_modify.call(self) if save_after_modify
            end
          end

          opts[:clearer] ||= proc do
            if (array = get_column_value(key)) && !array.empty?
              modified!(key)
              array.clear
              save_after_modify.call(self) if save_after_modify
            end
          end
        end
      end

      module DatasetMethods
        private

        # Support filtering by many_to_pg_array associations using a subquery.
        def many_to_pg_array_association_filter_expression(op, ref, obj)
          pk = ref.qualify(model.table_name, ref.primary_key)
          key = ref[:key]
          # :nocov:
          expr = case obj
          # :nocov:
          when Sequel::Model
            if (assoc_pks = obj.get_column_value(key)) && !assoc_pks.empty?
              Sequel[pk=>assoc_pks.to_a]
            end
          when Array
            if (assoc_pks = obj.map{|o| o.get_column_value(key)}.flatten.compact.uniq) && !assoc_pks.empty?
              Sequel[pk=>assoc_pks]
            end
          when Sequel::Dataset
            obj.select(ref.qualify(obj.model.table_name, ref[:key_column]).as(:key)).from_self.where{{pk=>any(:key)}}.select(1).exists
          end
          expr = Sequel::SQL::Constants::FALSE unless expr
          expr = add_association_filter_conditions(ref, obj, expr)
          association_filter_handle_inversion(op, expr, [pk])
        end

        # Support filtering by pg_array_to_many associations using a subquery.
        def pg_array_to_many_association_filter_expression(op, ref, obj)
          key = ref.qualify(model.table_name, ref[:key_column])
          # :nocov:
          expr = case obj
          # :nocov:
          when Sequel::Model
            if pkv = obj.get_column_value(ref.primary_key_method)
              Sequel.pg_array_op(key).contains(Sequel.pg_array([pkv], ref.array_type))
            end
          when Array
            if (pkvs = obj.map{|o| o.get_column_value(ref.primary_key_method)}.compact) && !pkvs.empty?
              Sequel.pg_array(key).overlaps(Sequel.pg_array(pkvs, ref.array_type))
            end
          when Sequel::Dataset
            Sequel.function(:coalesce, Sequel.pg_array_op(key).overlaps(obj.select{array_agg(ref.qualify(obj.model.table_name, ref.primary_key))}), Sequel::SQL::Constants::FALSE)
          end
          expr = Sequel::SQL::Constants::FALSE unless expr
          expr = add_association_filter_conditions(ref, obj, expr)
          association_filter_handle_inversion(op, expr, [key])
        end
      end
    end
  end
end
