lib/her/model/attributes.rb
module Her
module Model
# This module handles all methods related to model attributes
module Attributes
extend ActiveSupport::Concern
# Initialize a new object with data
#
# @param [Hash] attributes The attributes to initialize the object with
# @option attributes [Hash,Array] :_metadata
# @option attributes [Hash,Array] :_errors
# @option attributes [Boolean] :_destroyed
#
# @example
# class User
# include Her::Model
# end
#
# User.new(name: "Tobias")
# # => #<User name="Tobias">
#
# User.new do |u|
# u.name = "Tobias"
# end
# # => #<User name="Tobias">
def initialize(attributes = {})
attributes ||= {}
@metadata = attributes.delete(:_metadata) || {}
@response_errors = attributes.delete(:_errors) || {}
@destroyed = attributes.delete(:_destroyed) || false
attributes = self.class.default_scope.apply_to(attributes)
assign_attributes(attributes)
yield self if block_given?
run_callbacks :initialize
end
# Handles missing methods
#
# @private
def method_missing(method, *args, &blk)
if method.to_s =~ /[?=]$/ || @_her_attributes.include?(method)
# Extract the attribute
attribute = method.to_s.sub(/[?=]$/, '')
# Create a new `attribute` methods set
self.class.attributes(*attribute)
# Resend the method!
send(method, *args, &blk)
else
super
end
end
# @private
def respond_to_missing?(method, include_private = false)
method.to_s =~ /[?=]$/ || @_her_attributes.include?(method) || super
end
# Assign new attributes to a resource
#
# @example
# class User
# include Her::Model
# end
#
# user = User.find(1) # => #<User id=1 name="Tobias">
# user.assign_attributes(name: "Lindsay")
# user.changes # => { :name => ["Tobias", "Lindsay"] }
def assign_attributes(new_attributes)
if !new_attributes.respond_to?(:to_hash)
raise ArgumentError, "When assigning attributes, you must pass a hash as an argument."
end
# Coerce new_attributes to hash in case of strong parameters
new_attributes = new_attributes.to_hash
@_her_attributes ||= attributes
# Use setter methods first
unset_attributes = self.class.use_setter_methods(self, new_attributes)
# Then translate attributes of associations into association instances
associations = self.class.parse_associations(unset_attributes)
# Then merge the associations into @_her_attributes.
@_her_attributes.merge!(associations)
end
alias attributes= assign_attributes
def attributes
# The natural choice of instance variable naming here would be
# `@attributes`. Unfortunately that causes a naming clash when
# used with `ActiveModel` version >= 5.2.0.
# As of v5.2.0 `ActiveModel` checks to see if `ActiveRecord`
# attributes exist, and assumes that if the instance variable
# `@attributes` exists on the instance, it is because they are
# `ActiveRecord` attributes.
@_her_attributes ||= HashWithIndifferentAccess.new
end
# Handles returning true for the accessible attributes
#
# @private
def has_attribute?(attribute_name)
@_her_attributes.include?(attribute_name)
end
# Handles returning data for a specific attribute
#
# @private
def get_attribute(attribute_name)
@_her_attributes[attribute_name]
end
alias attribute get_attribute
# Return the value of the model `primary_key` attribute
def id
@_her_attributes[self.class.primary_key]
end
# Return `true` if the other object is also a Her::Model and has matching
# data
#
# @private
def ==(other)
other.is_a?(Her::Model) && @_her_attributes == other.attributes
end
# Delegate to the == method
#
# @private
def eql?(other)
self == other
end
# Delegate to @_her_attributes, allowing models to act correctly in code like:
# [ Model.find(1), Model.find(1) ].uniq # => [ Model.find(1) ]
# @private
def hash
@_her_attributes.hash
end
# Assign attribute value (ActiveModel convention method).
#
# @private
def attribute=(attribute, value)
@_her_attributes[attribute] = nil unless @_her_attributes.include?(attribute)
send("#{attribute}_will_change!") unless value == @_her_attributes[attribute]
@_her_attributes[attribute] = value
end
# Check attribute value to be present (ActiveModel convention method).
#
# @private
def attribute?(attribute)
@_her_attributes.include?(attribute) && @_her_attributes[attribute].present?
end
module ClassMethods
# Initialize a single resource
#
# @private
def instantiate_record(klass, parsed_data)
if (record = parsed_data[:data]) && record.is_a?(klass)
record
else
attributes = klass.parse(record).merge(_metadata: parsed_data[:metadata],
_errors: parsed_data[:errors])
klass.new(attributes).tap do |record_instance|
record_instance.send :clear_changes_information
record_instance.run_callbacks :find
end
end
end
# Initialize a collection of resources
#
# @private
def instantiate_collection(klass, parsed_data = {})
records = klass.extract_array(parsed_data).map do |record|
instantiate_record(klass, data: record)
end
Her::Collection.new(records, parsed_data[:metadata], parsed_data[:errors])
end
# Initialize a collection of resources with raw data from an HTTP request
#
# @param [Array] parsed_data
# @private
def new_collection(parsed_data)
instantiate_collection(self, parsed_data)
end
# Initialize a new object with the "raw" parsed_data from the parsing middleware
#
# @private
def new_from_parsed_data(parsed_data)
instantiate_record(self, parsed_data)
end
# Use setter methods of model for each key / value pair in params
# Return key / value pairs for which no setter method was defined on the
# model
#
# @private
def use_setter_methods(model, params = {})
reserved = [:id, model.class.primary_key, *model.class.association_keys]
model.class.attributes *params.keys.reject { |k| reserved.include?(k) }
setter_method_names = model.class.setter_method_names
params.each_with_object({}) do |(key, value), memo|
setter_method = "#{key}="
if setter_method_names.include?(setter_method)
model.send setter_method, value
else
memo[key.to_sym] = value
end
end
end
# Define attribute method matchers to automatically define them using
# ActiveModel's define_attribute_methods.
#
# @private
def define_attribute_method_matchers
attribute_method_suffix '='
attribute_method_suffix '?'
end
# Create a mutex for dynamically generated attribute methods or use one
# defined by ActiveModel.
#
# @private
def attribute_methods_mutex
@attribute_methods_mutex ||= begin
if generated_attribute_methods.respond_to? :mu_synchronize
generated_attribute_methods
else
Mutex.new
end
end
end
# Define the attributes that will be used to track dirty attributes and
# validations
#
# @param [Array] attributes
# @example
# class User
# include Her::Model
# attributes :name, :email
# end
def attributes(*attributes)
attribute_methods_mutex.synchronize do
define_attribute_methods attributes
end
end
# Define the accessor in which the API response errors (obtained from
# the parsing middleware) will be stored
#
# @param [Symbol] store_response_errors
#
# @example
# class User
# include Her::Model
# store_response_errors :server_errors
# end
def store_response_errors(value = nil)
store_her_data(:response_errors, value)
end
# Define the accessor in which the API response metadata (obtained from
# the parsing middleware) will be stored
#
# @param [Symbol] store_metadata
#
# @example
# class User
# include Her::Model
# store_metadata :server_data
# end
def store_metadata(value = nil)
store_her_data(:metadata, value)
end
# @private
def setter_method_names
@_her_setter_method_names ||= begin
instance_methods.each_with_object(Set.new) do |method, memo|
memo << method.to_s if method.to_s.end_with?('=')
end
end
end
private
# @private
def store_her_data(name, value)
class_eval <<-RUBY, __FILE__, __LINE__ + 1
if @_her_store_#{name} && value.present?
remove_method @_her_store_#{name}.to_sym
remove_method @_her_store_#{name}.to_s + '='
end
@_her_store_#{name} ||= begin
superclass.store_#{name} if superclass.respond_to?(:store_#{name})
end
return @_her_store_#{name} unless value
@_her_store_#{name} = value
define_method(value) { @#{name} }
define_method(value.to_s+'=') { |value| @#{name} = value }
RUBY
end
end
end
end
end