zammad/zammad

View on GitHub
lib/sequencer/state.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

class Sequencer::State
  include ::Mixin::RailsLogger
  include ::Mixin::StartFinishLogger

  def initialize(sequence, parameters: {}, expecting: nil)
    @index        = -1
    @units        = sequence.units
    @result_index = @units.count
    @values       = {}

    initialize_attributes(sequence.units)
    initialize_parameters(parameters)
    initialize_expectations(expecting || sequence.expecting)
  end

  # Stores a value for the given attribute. Value can be a regular object
  # or the result of a given code block.
  # The attribute gets validated against the .provides list of attributes.
  # In the case than an attribute gets provided that is not declared to
  # be provided an exception will be raised.
  #
  # @param [Symbol] attribute the attribute for which the value gets provided.
  # @param [Object] value the value that should get stored for the given attribute.
  # @yield [] executes the given block and takes the result as the value.
  # @yieldreturn [Object] the value for the given attribute.
  #
  # @example
  #  state.provide(:sum, 3)
  #
  # @example
  #  state.provide(:sum) do
  #    some_value = ...
  #    some_value * 3
  #  end
  #
  # @raise [RuntimeError] if the attribute is not provideable from the calling Unit
  #
  # @return [nil]
  def provide(attribute, value = nil)
    if provideable?(attribute)
      value = yield if block_given?
      set(attribute, value)
    else
      value = "UNEXECUTED BLOCK: #{caller(1..1).first}" if block_given?
      unprovideable_setter(attribute, value)
    end
  end

  # Returns the value of the given attribute.
  # The attribute gets validated against the .uses and .optionals
  # lists of attributes. In the case that an attribute gets used
  # that is not declared to be used or optional, an exception
  # gets raised.
  #
  # @param [Symbol] attribute the attribute for which the value is requested.
  #
  # @example
  #  state.use(:answer)
  #  #=> 42
  #
  # @raise [RuntimeError] if the attribute is not useable from the calling Unit
  #
  # @return [nil]
  def use(attribute)
    if useable?(attribute)
      get(attribute)
    else
      unaccessable_getter(attribute)
    end
  end

  # Returns the value of the given attribute.
  # The attribute DOES NOT get validated against the .uses list of attributes.
  # Use this method only in edge cases and prefer .optional macro and state.use otherwise.
  #
  # @param [Symbol] attribute the attribute for which the value is requested.
  #
  # @example
  #  state.optional(:answer)
  #  #=> 42
  #
  # @example
  #  state.optional(:unknown)
  #  #=> nil
  #
  # @return [Object, nil]
  def optional(attribute)
    return get(attribute) if @attributes.known?(attribute)

    logger.public_send(log_level[:optional]) { "Access to unknown optional attribute '#{attribute}'." }
    nil
  end

  # Checks if a value for the given attribute is provided.
  # The attribute DOES NOT get validated against the .uses list of attributes.
  # Use this method only in edge cases and prefer .optional macro and state.use otherwise.
  #
  # @param [Symbol] attribute the attribute which should get checked.
  #
  # @example
  #  state.provided?(:answer)
  #  #=> true
  #
  # @example
  #  state.provided?(:unknown)
  #  #=> false
  #
  # @return [Boolean]
  def provided?(attribute)
    optional(attribute) != nil
  end

  # Unsets the value for the given attribute.
  # The attribute gets validated against the .uses list of attributes.
  # In the case than an attribute gets unset that is not declared
  # to be used an exception will be raised.
  #
  # @param [Symbol] attribute the attribute for which the value gets unset.
  #
  # @example
  #  state.unset(:answer)
  #
  # @raise [RuntimeError] if the attribute is not useable from the calling Unit
  #
  # @return [nil]
  def unset(attribute)
    value = nil
    if useable?(attribute)
      set(attribute, value)
    else
      unprovideable_setter(attribute, value)
    end
  end

  # Handles state processing of the next Unit in the Sequence while executing
  # the given block. After the Unit is processed the state will get cleaned up
  # and no longer needed attribute values will get discarded.
  #
  # @yield [] executes the given block and handles the state changes before and afterwards.
  #
  # @example
  #  state.process do
  #   unit.process
  #  end
  #
  # @return [nil]
  def process
    @index += 1
    yield
    cleanup
  end

  # Handles state processing of the next Unit in the Sequence while executing
  # the given block. After the Unit is processed the state will get cleaned up
  # and no longer needed attribute values will get discarded.
  #
  # @example
  #  state.to_h
  #  #=> {"ssl_verify"=>true, "host"=>"192...", ...}
  #
  # @return [Hash{Symbol => Object}]
  def to_h
    available.index_with { |identifier| @values[identifier] }
  end

  private

  def available
    @attributes.select do |_identifier, attribute|
      @index.between?(attribute.from, attribute.till)
    end.keys
  end

  def unit(index = nil)
    @units[index || @index]
  end

  def provideable?(attribute)
    unit.provides.include?(attribute)
  end

  def useable?(attribute)
    return true if unit.uses.include?(attribute)

    unit.optional.include?(attribute)
  end

  def set(attribute, value)
    logger.public_send(log_level[:set]) { "Setting '#{attribute}' value (#{value.class.name}): #{value.inspect}" }
    @values[attribute] = value
  end

  def get(attribute)
    value = @values[attribute]
    logger.public_send(log_level[:get]) { "Getting '#{attribute}' value (#{value.class.name}): #{value.inspect}" }
    value
  end

  def unprovideable_setter(attribute, value)
    message = "Unprovideable attribute '#{attribute}' set with value (#{value.class.name}): #{value.inspect}"
    logger.error(message)
    raise message
  end

  def unaccessable_getter(attribute)
    message = "Unaccessable getter used for attribute '#{attribute}'"
    logger.error(message)
    raise message
  end

  def initialize_attributes(units)
    log_start_finish(log_level[:attribute_initialization][:start_finish], 'Attributes lifespan initialization') do
      @attributes = Sequencer::Units::Attributes.new(units.declarations)
      logger.public_send(log_level[:attribute_initialization][:attributes]) { "Attributes lifespan: #{@attributes.inspect}" }
    end
  end

  def initialize_parameters(parameters)
    logger.public_send(log_level[:parameter_initialization][:parameters]) { "Initializing Sequencer::State with initial parameters: #{parameters.inspect}" }

    log_start_finish(log_level[:parameter_initialization][:start_finish], 'Attribute value provisioning check and initialization') do

      @attributes.each do |identifier, attribute|

        if !attribute.will_be_used?
          logger.public_send(log_level[:parameter_initialization][:unused]) { "Attribute '#{identifier}' is provided by Unit(s) but never used." }
          next
        end

        init_param    = parameters.key?(identifier)
        provided_attr = attribute.will_be_provided?

        if !init_param && !provided_attr
          next if attribute.optional?

          message = "Attribute '#{identifier}' is used in Unit '#{unit(attribute.to).name}' (index: #{attribute.to}) but is not provided or given via initial parameters."
          logger.error(message)
          raise message
        end

        # skip if attribute is provided by an Unit but not
        # an initial parameter
        next if !init_param

        # update 'from' lifespan information for attribute
        # since it's provided via the initial parameter
        attribute.from = @index

        # set initial value
        set(identifier, parameters[identifier])
      end
    end
  end

  def initialize_expectations(expected_attributes)
    expected_attributes.each do |identifier|
      logger.public_send(log_level[:expectations_initialization]) { "Adding attribute '#{identifier}' to the list of expected result attributes." }
      @attributes[identifier].to = @result_index
    end
  end

  def cleanup
    log_start_finish(log_level[:cleanup][:start_finish], "State cleanup of Unit #{unit.name} (index: #{@index})") do

      @attributes.delete_if do |identifier, attribute|
        remove = !attribute.will_be_used?
        remove ||= attribute.till <= @index
        if remove && attribute.will_be_used?
          logger.public_send(log_level[:cleanup][:remove]) { "Removing unneeded attribute '#{identifier}': #{@values[identifier].inspect}" }
        end
        remove
      end
    end
  end

  def log_level
    @log_level ||= Sequencer.log_level_for(:state)
  end
end