maestrano/mno-enterprise

View on GitHub
core/app/models/mno_enterprise/base_resource.rb

Summary

Maintainability
A
1 hr
Test Coverage
# TODO: spec the ActiveRecord behaviour
# - processing of remote errors
# - response parsing (using data: [] format)
# - save methods
module MnoEnterprise
  class BaseResource
    include Her::Model
    include HerExtension::Validations::RemoteUniquenessValidation
    include GlobalID::Identification

    include_root_in_json :data
    use_api MnoEnterprise.mnoe_api_v1

    # TODO: spec that changed_attributes is empty
    # after a KLASS.all / KLASS.where etc..
    after_find { |res| res.instance_variable_set(:@changed_attributes, {}) }

    # Attributes common to all classes
    attributes :id, :created_at, :updated_at

    # Class query methods
    class << self
      # Delegate the following methods to `scoped`
      # Clear relation params for each class level query
      [:all, :where, :create, :find, :first_or_create, :first_or_initialize, :limit, :skip, :order_by, :sort_by, :order, :sort].each do |method|
        class_eval <<-RUBY, __FILE__, __LINE__ + 1
          def #{method}(*params)
            Her::Model::Relation.new(self).send(#{method.to_sym.inspect}, *params)
          end
        RUBY
      end

      # ActiveRecord Compatibility for Her
      def first(n = 1)
        return [] unless n > 0
        q = self.order_by('id.asc').limit(n)
        n == 1 ? q.to_a.first : q.to_a
      end

      # ActiveRecord Compatibility for Her
      def last(n = 1)
        return [] unless n > 0
        q = self.order_by('id.desc').limit(n)
        n == 1 ? q.to_a.first : q.to_a
      end

      # Find first record using a hash of attributes
      def find_by(hash)
        self.where(hash).limit(1).first
      end

      # ActiveRecord Compatibility for Her
      def exists?(hash)
        find_by(hash).present?
      end

      # ActiveRecord Compatibility for Her
      # Returns the class descending directly from MnoEnterprise::BaseResource, or
      # an abstract class, if any, in the inheritance hierarchy.
      #
      # If A extends MnoEnterprise::BaseResource, A.base_class will return A. If B descends from A
      # through some arbitrarily deep hierarchy, B.base_class will return A.
      #
      # If B < A and C < B and if A is an abstract_class then both B.base_class
      # and C.base_class would return B as the answer since A is an abstract_class.
      def base_class
        unless self < BaseResource
          raise Error, "#{name} doesn't belong in a hierarchy descending from BaseResource"
        end

        if superclass == BaseResource || superclass.abstract_class?
          self
        else
          superclass.base_class
        end
      end
    end

    #======================================================================
    # Instance methods
    #======================================================================
    # Returns a cache key that can be used to identify this record.
    #
    #   Product.new.cache_key     # => "products/new"
    #   Product.find(5).cache_key # => "products/5" (updated_at not available)
    #   Person.find(5).cache_key  # => "people/5-20071224150000" (updated_at available)
    #
    # You can also pass a list of named timestamps, and the newest in the list will be
    # used to generate the key:
    #
    #   Person.find(5).cache_key(:updated_at, :last_reviewed_at)
    #
    # Notes: copied from ActiveRecord
    def cache_key(*timestamp_names)
      case
        when new?
          "#{model_name.cache_key}/new"
        when timestamp_names.any?
          timestamp = max_updated_column_timestamp(timestamp_names)
          timestamp = timestamp.utc.to_s(:nsec)
          "#{model_name.cache_key}/#{id}-#{timestamp}"
        when timestamp = max_updated_column_timestamp
          timestamp = timestamp.utc.to_s(:nsec)
          "#{model_name.cache_key}/#{id}-#{timestamp}"
        else
          "#{model_name.cache_key}/#{id}"
      end
    end

    def max_updated_column_timestamp(timestamp_names = [:updated_at])
      timestamp_names
          .map { |attr| self[attr] }
          .compact
          .map(&:to_time)
          .max
    end

    # Clear the record association cache
    def clear_association_cache
      self.class.associations[:has_many].each do |assoc|
        instance_variable_set(:"@_her_association_#{assoc[:name]}", nil)
        attributes.delete(assoc[:name].to_s)
      end
    end

    # ActiveRecord Compatibility for Her
    def read_attribute(attr_name)
      get_attribute(attr_name)
    end

    # ActiveRecord Compatibility for Her
    def write_attribute(attr_name, value)
      assign_attributes(attr_name => value)
    end
    alias []= write_attribute

    # ActiveRecord Compatibility for Her
    def save(options={})
      if perform_validations(options)
        ret = super()
        process_response_errors
        ret
      else
        false
      end
    end

    # ActiveRecord Compatibility for Her
    def save!(options={})
      if perform_validations(options)
        ret = super()
        process_response_errors
        raise_record_invalid if self.errors.any?
        ret
      else
        raise_record_invalid
      end
    end

    # ActiveRecord Compatibility for Her
    def reload(options = nil)
      @attributes.update(self.class.find(self.id).attributes)
      self.run_callbacks :find
      self
    end

    # ActiveRecord Compatibility for Her
    def update(attributes)
      assign_attributes(attributes)
      save
    end

    # Reset the ActiveModel hash containing all attribute changes
    # Useful when initializing a existing resource using a hash fetched
    # via http call (e.g.: MnoEnterprise::User.authenticate)
    def clear_attribute_changes!
      self.instance_variable_set(:@changed_attributes, {})
    end

    # Returns true if +comparison_object+ is the same exact object, or +comparison_object+
    # is of the same type and +self+ has an ID and it is equal to +comparison_object.id+.
    #
    # Note that new records are different from any other record by definition, unless the
    # other record is the receiver itself. Besides, if you fetch existing records with
    # +select+ and leave the ID out, you're on your own, this predicate will return false.
    #
    # Note also that destroying a record preserves its ID in the model instance, so deleted
    # models are still comparable.
    def ==(comparison_object)
      super ||
        comparison_object.instance_of?(self.class) &&
        !id.nil? &&
        comparison_object.id == id
    end
    alias :eql? :==

    protected
      # Process errors from the servers and add them to the
      # model
      # Servers are returned using the jsonapi format
      # E.g.:
      # errors: [
      #   {
      #     :id=>"f720ca10-b104-0132-dbc0-600308937d74",
      #     :href=>"http://maestrano.github.io/enterprise/#users-users-list-post",
      #     :status=>"400",
      #     :code=>"name-can-t-be-blank",
      #     :title=>"Name can't be blank",
      #     :detail=>"Name can't be blank"
      #     :attribute => "name"
      #     :value => "can't be blank"
      #   }
      # ]
      def process_response_errors
        if self.response_errors && self.response_errors.any?
          self.response_errors.each do |error|
            key = error[:attribute] && !error[:attribute].empty? ? error[:attribute] : :base
            val = error[:value] && !error[:value].empty? ? error[:value] : error[:title]
            self.errors[key.to_sym] << val
          end
        end
      end

      # ActiveRecord Compatibility for Her
      def raise_record_invalid
        raise(Her::Errors::ResourceInvalid.new(self))
      end

      # ActiveRecord Compatibility for Her
      def perform_validations(options={}) # :nodoc:
        # errors.blank? to avoid the unexpected case when errors is nil...
        # -> THIS IS A TEMPORARY UGLY FIX
        options[:validate] == false || self.errors.nil? || valid?(options[:context])
      end

  end
end