rom-rb/rom-repository

View on GitHub
lib/rom/repository/command_compiler.rb

Summary

Maintainability
B
5 hrs
Test Coverage
require 'dry/core/inflector'
require 'dry/core/cache'

require 'rom/commands'
require 'rom/repository/command_proxy'

module ROM
  class Repository
    # Builds commands for relations.
    #
    # This class is used by repositories to automatically create commands for
    # their relations. This is used both by `Repository#command` method and
    # `commands` repository class macros.
    #
    # @api private
    class CommandCompiler
      extend Dry::Core::Cache

      # Return a specific command type for a given adapter and relation AST
      #
      # This class holds its own registry where all generated commands are being
      # stored
      #
      # CommandProxy is returned for complex command graphs as they expect root
      # relation name to be present in the input, which we don't want to have
      # in repositories. It might be worth looking into removing this requirement
      # from rom core Command::Graph API.
      #
      # @overload [](container, type, adapter, ast, plugins, options)
      #
      #   @param container [ROM::Container] container where relations are stored
      #   @param type [Symbol] The type of command
      #   @param adapter [Symbol] The adapter identifier
      #   @param ast [Array] The AST representation of a relation
      #   @param plugins [Array<Symbol>] A list of optional command plugins that should be used
      #
      #   @return [Command, CommandProxy]
      #
      # @api private
      def self.[](*args)
        fetch_or_store(args.hash) do
          container, type, adapter, ast, plugins, options = args

          graph_opts = new(type, adapter, container, registry, plugins, options).visit(ast)

          command = ROM::Commands::Graph.build(registry, graph_opts)

          if command.graph?
            CommandProxy.new(command)
          elsif command.lazy?
            command.unwrap
          else
            command
          end
        end
      end

      # @api private
      def self.registry
        @__registry__ ||= Hash.new { |h, k| h[k] = {} }
      end

      # @!attribute [r] id
      #   @return [Symbol] The command type registry identifier
      attr_reader :id

      # @!attribute [r] adapter
      #   @return [Symbol] The adapter identifier ie :sql or :http
      attr_reader :adapter

      # @!attribute [r] container
      #   @return [ROM::Container] rom container with relations and gateways
      attr_reader :container

      # @!attribute [r] registry
      #   @return [Hash] local registry where commands will be stored during compilation
      attr_reader :registry

      # @!attribute [r] plugins
      #   @return [Array<Symbol>] a list of optional plugins that will be enabled for commands
      attr_reader :plugins

      # @!attribute [r] options
      #   @return [Hash] Additional command options
      attr_reader :options

      # @api private
      def initialize(id, adapter, container, registry, plugins, options)
        @id = id
        @adapter = adapter
        @registry = registry
        @container = container
        @plugins = Array(plugins)
        @options = options
      end

      # @api private
      def type
        @_type ||= Commands.const_get(Dry::Core::Inflector.classify(id))[adapter]
      rescue NameError
        nil
      end

      # @api private
      def visit(ast, *args)
        name, node = ast
        __send__(:"visit_#{name}", node, *args)
      end

      private

      # @api private
      def visit_relation(node, parent_relation = nil)
        name, meta, header = node
        other = visit(header, name)

        if type
          register_command(name, type, meta, parent_relation)

          default_mapping =
            if meta[:combine_type] == :many
              name
            else meta[:combine_type] == :one
              { Dry::Core::Inflector.singularize(name).to_sym => name }
            end

          mapping =
            if parent_relation
              associations = container.relations[parent_relation].associations

              assoc =
                if associations.key?(meta[:combine_name])
                  associations[meta[:combine_name]]
                elsif associations.key?(name)
                  associations[name]
                end

              if assoc
                { assoc.target.key => assoc.target.dataset }
              else
                default_mapping
              end
            else
              default_mapping
            end

          if other.size > 0
            [mapping, [type, other]]
          else
            [mapping, type]
          end
        else
          registry[name][id] = container.commands[name][id]
          [name, id]
        end
      end

      # @api private
      def visit_header(node, *args)
        node.map { |n| visit(n, *args) }.compact
      end

      # @api private
      def visit_attribute(*args)
        nil
      end

      # Build a command object for a specific relation
      #
      # The command will be prepared for handling associations if it's a combined
      # relation. Additional plugins will be enabled if they are configured for
      # this compiler.
      #
      # @param [Symbol] rel_name A relation identifier from the container registry
      # @param [Symbol] type The command type
      # @param [Hash] meta Meta information from relation AST
      # @param [Symbol] parent_relation Optional parent relation identifier
      #
      # @return [ROM::Command]
      #
      # @api private
      def register_command(rel_name, type, meta, parent_relation = nil)
        relation = container.relations[rel_name]

        type.create_class(rel_name, type) do |klass|
          klass.result(meta.fetch(:combine_type, result))

          if meta[:combine_type]
            setup_associates(klass, relation, meta, parent_relation)
          end

          finalize_command_class(klass, relation)

          registry[rel_name][type] = klass.build(relation, input: relation.input_schema)
        end
      end

      # Return default result type
      #
      # @return [Symbol]
      #
      # @api private
      def result
        options.fetch(:result, :one)
      end

      # Sets up `associates` plugin for a given command class and relation
      #
      # @param [Class] klass The command class
      # @param [Relation] relation The relation for the command
      #
      # @api private
      def setup_associates(klass, relation, meta, parent_relation)
        assoc_name =
          if relation.associations.key?(parent_relation)
            parent_relation
          else
            singular_name = Dry::Core::Inflector.singularize(parent_relation).to_sym
            singular_name if relation.associations.key?(singular_name)
          end

        if assoc_name
          klass.associates(assoc_name)
        else
          keys = meta[:keys].invert.to_a.flatten
          klass.associates(parent_relation, key: keys)
        end
      end

      # Setup a command class for a specific relation
      #
      # Every gateway may provide custom command extensions via
      # `Gateway#extend_command_class`. Furthermore, restrictible commands like
      # `Update` or `Delete` will be extended with relation view methods, so things
      # like `delete_user.by_id(1).call` becomes available.
      #
      # @param [Class] klass The command class
      # @param [Relation] relation The command relation
      #
      # @return [Class]
      #
      # @api private
      def finalize_command_class(klass, relation)
        # TODO: this is a copy-paste from rom's FinalizeCommands, we are missing
        #       an interface!
        gateway = container.gateways[relation.class.gateway]
        gateway.extend_command_class(klass, relation.dataset)

        klass.extend_for_relation(relation) if klass.restrictable

        plugins.each do |plugin|
          klass.use(plugin)
        end
      end
    end
  end
end