lanej/cistern

View on GitHub
lib/cistern/attributes.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

module Cistern::Attributes
  PROTECTED_METHODS = [:cistern, :service, :identity, :collection].freeze
  TRUTHY = [true, 'On', 'ON', 'on', 'True', 'TRUE', 'true', '1', 1].freeze

  module ClassMethods
    def parsers
      @parsers ||= {
        array:          ->(v, _) { [*v] },
        boolean:        ->(v, _) { TRUTHY.include?(v) },
        date:           ->(v, _) { v.is_a?(Date) ? v : v && Date.parse(v.to_s) },
        float:          ->(v, _) { v&.to_f },
        integer:        ->(v, _) { v&.to_i },
        strict_integer: ->(v, _) { Integer(v) },
        string:         ->(v, _) { v&.to_s },
        time:           ->(v, _) { v.is_a?(Time) ? v : v && Time.parse(v.to_s) },
      }
    end

    def squasher(tree, path)
      tree.is_a?(::Hash) ? squasher(tree[path.shift], path) : tree
    end

    def transforms
      @transforms ||= {
        squash: proc do |_, _v, options|
          v      = Cistern::Hash.stringify_keys(_v)
          squash = options[:squash]

          v.is_a?(::Hash) ? squasher(v, squash.dup) : v
        end,
        none: ->(_, v, _) { v }
      }
    end

    def default_parser
      @default_parser ||= ->(v, _opts) { v }
    end

    def aliases
      @aliases ||= Hash.new { |h, k| h[k] = [] }
    end

    def attributes
      @attributes ||= parent_attributes || {}
    end

    def attribute(name, options = {})
      name_sym = name.to_sym

      if attributes.key?(name_sym)
        fail(ArgumentError, "#{self.name} attribute[#{name_sym}] specified more than once")
      end

      add_coverage(options)

      normalize_options(options)

      attributes[name_sym] = options

      define_attribute_reader(name_sym, options) unless options[:reader] == false
      define_attribute_writer(name_sym, options) unless options[:writer] == false

      name_sym
    end

    def identity(*args, **kwargs)
      args.any? ? @identity = attribute(*args, **kwargs) : (@identity ||= parent_identity)
    end

    def ignore_attributes(*args, **kwargs)
      @ignored_attributes = args
    end

    def ignored_attributes
      @ignored_attributes ||= []
    end

    protected

    def add_coverage(options)
      return unless defined? Cistern::Coverage

      attribute_call = Cistern::Coverage.find_caller_before('cistern/attributes.rb')

      # Only use DSL attribute calls from within a model
      if attribute_call && attribute_call.label.start_with?('<class:')
        options[:coverage_file] = attribute_call.absolute_path
        options[:coverage_line] = attribute_call.lineno
        options[:coverage_hits] = 0
      end
    end

    def define_attribute_reader(name, options)
      send(:define_method, name) do
        read_attribute(name)
      end unless instance_methods.include?(name)

      send(:alias_method, "#{name}?", name) if options[:type] == :boolean

      options[:aliases].each { |new_alias| aliases[new_alias] << name }
    end

    def define_attribute_writer(name, options)
      return if instance_methods.include?("#{name}=".to_sym)

      send(:define_method, "#{name}=") { |value| write_attribute(name, value) }
    end

    private

    def normalize_options(options)
      options[:squash] = Array(options[:squash]).map(&:to_s) if options[:squash]
      options[:aliases] = Array(options[:aliases] || options[:alias]).map { |a| a.to_sym }

      transform = options.key?(:squash) ? :squash : :none
      options[:transform] ||= transforms.fetch(transform)
      options[:parser] ||= parsers[options[:type]] || default_parser
    end

    def parent_attributes
      superclass && superclass.respond_to?(:attributes) && superclass.attributes.dup
    end

    def parent_identity
      superclass && superclass.respond_to?(:identity) && superclass.identity
    end
  end

  module InstanceMethods
    def clone_attributes
      Marshal.load Marshal.dump(attributes)
    end

    def read_attribute(name)
      key = name.to_sym

      options = self.class.attributes[key]
      default = options[:default]

      # record the attribute was accessed
      if defined?(Cistern::Coverage) && options[:coverage_hits]
        options[:coverage_hits] += 1
      end

      default = Marshal.load(Marshal.dump(default)) unless default.nil?

      attributes.fetch(key, default)
    end

    def write_attribute(name, value)
      options = self.class.attributes[name] || {}

      transform = options[:transform]

      parser = options[:parser]

      transformed = transform.call(name, value, options)

      new_value = parser.call(transformed, options)
      attribute = name.to_s.to_sym

      previous_value = read_attribute(name)

      attributes[attribute] = new_value

      changed!(attribute, previous_value, new_value)

      new_value
    end

    def attributes
      @attributes ||= {}
    end

    def dup
      super.tap { |m| m.set_attributes attributes.dup }
    end

    def identity
      key = self.class.identity

      public_send(key) if key
    end

    def identity=(new_identity)
      key = self.class.identity

      if key
        public_send("#{key}=", new_identity)
      else
        fail ArgumentError, 'Identity not specified'
      end
    end

    # Update model's attributes.  New attributes take precedence over existing attributes.
    #
    # This is bst called within a {Cistern::Model#save}, when {#new_attributes} represents a recently presented remote
    # resource.  {#dirty_attributes} is cleared after merging.
    #
    # @param new_attributes [Hash] attributes to merge with current attributes
    def merge_attributes(new_attributes = {})
      _merge_attributes(new_attributes)

      changed.clear

      self
    end

    alias attributes= merge_attributes

    # Update model's attributes.  New attributes take precedence over existing attributes.
    #
    # This is best called within a {Cistern::Model#update}, when {#new_attributes} represents attributes to be
    # presented to a remote service. {#dirty_attributes} will contain the valid portion of {#new_attributes}
    #
    # @param new_attributes [Hash] attributes to merge with current attributes
    def stage_attributes(new_attributes = {})
      _merge_attributes(new_attributes)
      self
    end

    def new_record?
      identity.nil?
    end

    # Require specification of certain attributes
    #
    # @raise [ArgumentError] if any requested attribute does not have a value
    # @return [Hash] of matching attributes
    def requires(*args, **kwargs)
      missing, required = missing_attributes(args)

      if missing.length == 1
        fail(ArgumentError, "#{missing.keys.first} is required for this operation")
      elsif missing.any?
        fail(ArgumentError, "#{missing.keys[0...-1].join(', ')} and #{missing.keys[-1]} are required for this operation")
      end

      required
    end

    # Require specification of one or more attributes.
    #
    # @raise [ArgumentError] if no requested attributes have values
    # @return [Hash] of matching attributes
    def requires_one(*args, **kwargs)
      missing, required = missing_attributes(args)

      if missing.length == args.length
        fail(ArgumentError, "#{missing.keys[0...-1].join(', ')} or #{missing.keys[-1]} are required for this operation")
      end

      required
    end

    def dirty?
      changed.any?
    end

    def dirty_attributes
      changed.inject({}) { |r, (k, (_, v))| r.merge(k => v) }
    end

    def changed
      @changes ||= {}
    end

    def request_attributes(set = attributes)
      set.inject({}) do |a,(k,v)|
        aliases = self.class.attributes[k.to_sym][:aliases]
        aliases << k if aliases.empty?
        aliases.each_with_object(a) { |n,r| r[n.to_s] = v }
      end
    end

    def dirty_request_attributes
      request_attributes(dirty_attributes)
    end

    private

    def missing_attributes(keys)
      keys.map(&:to_sym).reduce({}) { |a,e| a.merge(e => public_send("#{e}")) }
        .partition { |_,v| v.nil? }
        .map { |s| Hash[s] }
    end

    def changed!(attribute, from, to)
      changed[attribute] = if existing = changed[attribute]
                             [existing.first, to]
                           else
                             [from, to]
                           end
    end

    def _merge_attributes(new_attributes)
      protected_methods  = (Cistern::Model.instance_methods - PROTECTED_METHODS)
      ignored_attributes = self.class.ignored_attributes
      specifications     = self.class.attributes
      class_aliases      = self.class.aliases

      # this has the side effect of dup'ing the incoming hash
      new_attributes = Cistern::Hash.stringify_keys(new_attributes)

      new_attributes.each do |key, value|
        symbol_key = key.to_sym

        # find nested paths
        value.is_a?(::Hash) && specifications.each do |name, options|
          if options[:squash] && options[:squash].first == key
            send("#{name}=", key => value)
          end
        end

        next if ignored_attributes.include?(symbol_key)

        if class_aliases.key?(symbol_key)
          class_aliases[symbol_key].each { |attribute_alias| public_send("#{attribute_alias}=", value) }
        end

        assignment_method = "#{key}="

        if !protected_methods.include?(symbol_key) && self.respond_to?(assignment_method, true)
          public_send(assignment_method, value)
        end
      end
    end

    protected

    def set_attributes(attributes)
      @attributes = attributes
    end
  end
end