class Pakyow::Data::Sources::Relational::Command

Public Class Methods

new(name, block:, source:, provides_dataset:, creates:, updates:, deletes:) click to toggle source
# File lib/pakyow/data/sources/relational/command.rb, line 15
def initialize(name, block:, source:, provides_dataset:, creates:, updates:, deletes:)
  @name, @block, @source, @provides_dataset, @creates, @updates, @deletes = name, block, source, provides_dataset, creates, updates, deletes
end

Public Instance Methods

call(values = {}) { |final_result| ... } click to toggle source
# File lib/pakyow/data/sources/relational/command.rb, line 19
def call(values = {})
  future_associated_changes = []

  if values
    # Enforce required attributes.
    #
    @source.class.attributes.each do |attribute_name, attribute|
      if attribute.meta[:required]
        if @creates && !values.include?(attribute_name)
          raise NotNullViolation.new_with_message(attribute: attribute_name)
        end

        if values.include?(attribute_name) && values[attribute_name].nil?
          raise NotNullViolation.new_with_message(attribute: attribute_name)
        end
      end
    end

    # Fail if unexpected values were passed.
    #
    values.keys.each do |key|
      key = key.to_sym
      unless @source.class.attributes.include?(key) || @source.class.association_with_name?(key)
        raise UnknownAttribute.new_with_message(attribute: key, source: @source.class.__object_name.name)
      end
    end

    # Coerce values into the appropriate type.
    #
    final_values = values.each_with_object({}) { |(key, value), values_hash|
      key = key.to_sym

      begin
        if attribute = @source.class.attributes[key]
          if value.is_a?(Proxy) || value.is_a?(Result) || value.is_a?(Object)
            raise TypeMismatch.new_with_message(type: value.class, mapping: attribute.meta[:mapping])
          end

          values_hash[key] = value.nil? ? value : attribute[value]
        else
          values_hash[key] = value
        end
      rescue TypeError, Dry::Types::CoercionError => error
        raise TypeMismatch.build(error, type: value.class, mapping: attribute.meta[:mapping])
      end
    }

    # Update timestamp fields.
    #
    if timestamp_fields = @source.class.timestamp_fields
      if @creates
        timestamp_fields.values.each do |timestamp_field|
          final_values[timestamp_field] = Time.now
        end
      # Don't update timestamps if we aren't also updating other values.
      #
      elsif values.any? && timestamp_field = timestamp_fields[@name]
        final_values[timestamp_field] = Time.now
      end
    end

    if @creates
      # Set default values.
      #
      @source.class.attributes.each do |attribute_name, attribute|
        if !final_values.include?(attribute_name) && attribute.meta.include?(:default)
          default = attribute.meta[:default]
          final_values[attribute_name] = if default.is_a?(Proc)
            default.call
          else
            default
          end
        end
      end
    end

    # Enforce constraints on association values passed by access name.
    #
    @source.class.associations.values.flatten.select { |association|
      final_values.key?(association.name)
    }.each do |association|
      association_value = raw_result(final_values[association.name])

      case association_value
      when Proxy
        if association_value.source.class.__object_name.name == association.associated_source.__object_name.name
          if association.result_type == :one && (association_value.count > 1 || (@updates && @source.count > 1))
            raise ConstraintViolation.new_with_message(
              :associate_multiple,
              association: association.name
            )
          end
        else
          raise TypeMismatch.new_with_message(
            :associate_wrong_source,
            source: association_value.source.class.__object_name.name,
            association: association.name
          )
        end
      when Object
        if association.result_type == :one
          if association_value.originating_source
            if association_value.originating_source.__object_name.name == association.associated_source.__object_name.name
              if association.associated_source.instance.send(:"by_#{association.associated_source.primary_key_field}", association_value[association.associated_source.primary_key_field]).count == 0
                raise ConstraintViolation.new_with_message(
                  :associate_missing,
                  source: association.name,
                  field: association.associated_source.primary_key_field,
                  value: association_value[association.associated_source.primary_key_field]
                )
              end
            else
              raise TypeMismatch.new_with_message(
                :associate_wrong_object,
                source: association_value.originating_source.__object_name.name,
                association: association.name
              )
            end
          else
            raise TypeMismatch.new_with_message(
              :associate_unknown_object,
              association: association.name
            )
          end
        else
          raise TypeMismatch.new_with_message(
            :associate_wrong_type,
            type: association_value.class,
            association: association.name
          )
        end
      when Array
        if association.result_type == :many
          if association_value.any? { |value| !value.is_a?(Object) }
            raise TypeMismatch.new_with_message(
              :associate_many_not_object,
              association: association.name
            )
          else
            if association_value.any? { |value| value.originating_source.nil? }
              raise TypeMismatch.new_with_message(
                :associate_unknown_object,
                association: association.name
              )
            else
              if association_value.find { |value| value.originating_source != association.associated_source }
                raise TypeMismatch.new_with_message(
                  :associate_many_wrong_source,
                  association: association.name,
                  source: association.associated_source_name
                )
              else
                associated_column_value = association_value.map { |object| object[association.associated_source.primary_key_field] }
                associated_object_query = association.associated_source.instance.send(
                  :"by_#{association.associated_source.primary_key_field}", associated_column_value
                )

                if associated_object_query.count != association_value.count
                  raise ConstraintViolation.new_with_message(
                    :associate_many_missing,
                    association: association.name
                  )
                end
              end
            end
          end
        else
          raise ConstraintViolation.new_with_message(
            :associate_multiple,
            association: association.name
          )
        end
      when NilClass
      else
        raise TypeMismatch.new_with_message(
          :associate_wrong_type,
          type: association_value.class,
          association: association.name
        )
      end
    end

    # Enforce constraints for association values passed by foreign key.
    #
    @source.class.associations.values.flatten.select { |association|
      association.type == :belongs && final_values.key?(association.foreign_key_field) && !final_values[association.foreign_key_field].nil?
    }.each do |association|
      associated_column_value = final_values[association.foreign_key_field]
      associated_object_query = association.associated_source.instance.send(
        :"by_#{association.associated_query_field}", associated_column_value
      )

      if associated_object_query.count == 0
        raise ConstraintViolation.new_with_message(
          :associate_missing,
          source: association.name,
          field: association.associated_query_field,
          value: associated_column_value
        )
      end
    end

    # Set values for associations passed by access name.
    #
    @source.class.associations.values.flatten.select { |association|
      final_values.key?(association.name)
    }.each do |association|
      case association.specific_type
      when :belongs_to
        association_value = raw_result(final_values.delete(association.name))
        final_values[association.query_field] = case association_value
        when Proxy
          if association_value.one.nil?
            nil
          else
            association_value.one[association.associated_source.primary_key_field]
          end
        when Object
          association_value[association.associated_source.primary_key_field]
        when NilClass
          nil
        end
      when :has_one, :has_many
        future_associated_changes << [association, final_values.delete(association.name)]
      end
    end
  end

  original_dataset = if @updates
    # Hold on to the original values so we can update them locally.
    @source.dup.to_a
  else
    nil
  end

  unless @provides_dataset || @updates
    # Cache the result prior to running the command.
    @source.to_a
  end

  @source.transaction do
    if @deletes
      @source.class.associations.values.flatten.select(&:dependents?).each do |association|
        dependent_values = @source.class.container.connection.adapter.restrict_to_attribute(
          @source.class.primary_key_field, @source
        )

        # If objects are located in two different connections, fetch the raw values.
        #
        unless @source.class.container.connection == association.associated_source.container.connection
          dependent_values = dependent_values.map { |dependent_value|
            dependent_value[@source.class.primary_key_field]
          }
        end

        if association.type == :through
          joining_data = association.joining_source.instance.send(
            :"by_#{association.right_foreign_key_field}",
            dependent_values
          )

          dependent_data = association.associated_source.instance.send(
            :"by_#{association.associated_source.primary_key_field}",
            association.associated_source.container.connection.adapter.restrict_to_attribute(
              association.left_foreign_key_field, joining_data
            ).map { |result|
              result[association.left_foreign_key_field]
            }
          )

          case association.dependent
          when :delete
            joining_data.delete
          when :nullify
            joining_data.update(association.right_foreign_key_field => nil)
          end
        else
          dependent_data = association.associated_source.instance.send(
            :"by_#{association.associated_query_field}",
            dependent_values
          )
        end

        case association.dependent
        when :delete
          dependent_data.delete
        when :nullify
          unless association.type == :through
            dependent_data.update(association.associated_query_field => nil)
          end
        when :raise
          dependent_count = dependent_data.count
          if dependent_count > 0
            dependent_name = if dependent_count > 1
              Support.inflector.pluralize(association.associated_source_name)
            else
              Support.inflector.singularize(association.associated_source_name)
            end

            raise ConstraintViolation.new_with_message(
              :dependent_delete,
              source: @source.class.__object_name.name,
              count: dependent_count,
              dependent: dependent_name
            )
          end
        end
      end
    end

    if @creates || @updates
      # Ensure that has_one associations only have one associated object.
      #
      @source.class.associations[:belongs_to].flat_map { |belongs_to_association|
        belongs_to_association.associated_source.associations[:has_one].select { |has_one_association|
          has_one_association.associated_source == @source.class &&
            has_one_association.associated_query_field == belongs_to_association.query_field
        }
      }.each do |association|
        value = final_values.dig(
          association.associated_name, association.query_field
        ) || final_values.dig(association.associated_query_field)

        if value
          @source.class.instance.tap do |impacted_source|
            impacted_source.__setobj__(
              @source.class.container.connection.adapter.result_for_attribute_value(
                association.associated_query_field, value, impacted_source
              )
            )

            impacted_source.update(association.associated_query_field => nil)
          end
        end
      end
    end

    command_result = @source.instance_exec(final_values, &@block)

    final_result = if @updates
      # For updates, we fetch the values prior to performing the update and
      # return a source containing locally updated values. This lets us see
      # the original values but prevents us from fetching twice.

      @source.class.container.source(@source.class.__object_name.name).tap do |updated_source|
        updated_source.__setobj__(
          @source.class.container.connection.adapter.result_for_attribute_value(
            @source.class.primary_key_field, command_result, updated_source
          )
        )

        updated_source.instance_variable_set(:@results, original_dataset.map { |original_object|
          new_object = original_object.class.new(original_object.values.merge(final_values))
          new_object.originating_source = original_object.originating_source
          new_object
        })

        updated_source.instance_variable_set(:@original_results, original_dataset)
      end
    elsif @provides_dataset
      @source.dup.tap { |source|
        source.__setobj__(command_result)
      }
    else
      @source
    end

    if @creates || @updates
      # Update records associated with the data we just changed.
      #
      future_associated_changes.each do |association, association_value|
        association_value = raw_result(association_value)
        associated_dataset = case association_value
        when Proxy
          association_value
        when Object, Array
          updatable = Array.ensure(association_value).map { |value|
            case value
            when Object
              value[association.associated_source.primary_key_field]
            else
              value
            end
          }

          association.associated_source.instance.send(
            :"by_#{association.associated_source.primary_key_field}", updatable
          )
        when NilClass
          nil
        end

        if association.type == :through
          associated_column_value = final_result.class.container.connection.adapter.restrict_to_attribute(
            association.query_field, final_result
          )

          # If objects are located in two different connections, fetch the raw values.
          #
          if association.joining_source.container.connection == final_result.class.container.connection
            disassociate_column_value = associated_column_value
          else
            disassociate_column_value = associated_column_value.map { |value|
              value[association.query_field]
            }
          end

          # Disassociate old data.
          #
          association.joining_source.instance.send(
            :"by_#{association.right_foreign_key_field}",
            disassociate_column_value
          ).delete

          if associated_dataset
            associated_dataset_source = case raw_result(associated_dataset)
            when Proxy
              associated_dataset.source
            else
              associated_dataset
            end

            # Ensure that has_one through associations only have one associated object.
            #
            if association.result_type == :one
              joined_column_value = association.associated_source.container.connection.adapter.restrict_to_attribute(
                association.associated_source.primary_key_field, associated_dataset_source
              )

              # If objects are located in two different connections, fetch the raw values.
              #
              unless association.joining_source.container.connection == association.associated_source.container.connection
                joined_column_value = joined_column_value.map { |value|
                  value[association.associated_source.primary_key_field]
                }
              end

              association.joining_source.instance.send(
                :"by_#{association.left_foreign_key_field}",
                joined_column_value
              ).delete
            end

            # Associate the correct data.
            #
            associated_column_value.each do |result|
              association.associated_source.container.connection.adapter.restrict_to_attribute(
                association.associated_source.primary_key_field, associated_dataset_source
              ).each do |associated_result|
                association.joining_source.instance.command(:create).call(
                  association.left_foreign_key_field => associated_result[association.associated_source.primary_key_field],
                  association.right_foreign_key_field => result[association.source.primary_key_field]
                )
              end
            end
          end
        else
          if final_result.one
            associated_column_value = final_result.one[association.query_field]

            # Disassociate old data.
            #
            association.associated_source.instance.send(
              :"by_#{association.associated_query_field}", associated_column_value
            ).update(association.associated_query_field => nil)

            # Associate the correct data.
            #
            if associated_dataset
              associated_dataset.update(
                association.associated_query_field => associated_column_value
              )

              # Update the column value in passed objects.
              #
              case association_value
              when Proxy
                association_value.source.reload
              when Object, Array
                Array.ensure(association_value).each do |object|
                  values = object.values.dup
                  values[association.associated_query_field] = associated_column_value
                  object.instance_variable_set(:@values, values.freeze)
                end
              end
            end
          end
        end
      end
    end

    yield final_result if block_given?
    final_result
  end
end

Private Instance Methods

raw_result(value) click to toggle source
# File lib/pakyow/data/sources/relational/command.rb, line 517
def raw_result(value)
  if value.is_a?(Result)
    value.__getobj__
  elsif value.is_a?(Array)
    value.map { |each_value|
      raw_result(each_value)
    }
  else
    value
  end
end