require 'buff/extensions' require 'json' module VariaModel require_relative 'varia_model/version' require_relative 'varia_model/attributes' module ClassMethods ASSIGNMENT_MODES = [ :whitelist, :carefree ] # @return [VariaModel::Attributes] def attributes @attributes ||= Attributes.new end # @return [Hashie::Mash] def validations @validations ||= Hashie::Mash.new end # @return [Symbol] def assignment_mode @assignment_mode ||= :whitelist end # Set the attribute mass assignment mode # * :whitelist - only attributes defined on the class will have values set # * :carefree - values will be set for attributes that are not explicitly defined # in the class definition # # @param [Symbol] mode # an assignment mode to use @see {ASSIGNMENT_MODES} def set_assignment_mode(mode) unless ASSIGNMENT_MODES.include?(mode) raise ArgumentError, "unknown assignment mode: #{mode}" end @assignment_mode = mode end # @param [#to_s] name # @option options [Symbol, Array] :type # @option options [Boolean] :required # @option options [Object] :default # @option options [Proc] :coerce def attribute(name, options = {}) name = name.to_s options[:type] = Array(options[:type]) options[:required] ||= false register_attribute(name, options) define_mimic_methods(name, options) end # @param [String] name # # @return [Array] def validations_for(name) self.validations[name] ||= Array.new end # @param [Constant, Array] types # @param [VariaModel] model # @param [String] key # # @return [Array] def validate_kind_of(types, model, key) errors = Array.new types = types.uniq matches = false types.each do |type| if model.get_attribute(key).is_a?(type) matches = true break end end if matches [ :ok, "" ] else types_msg = types.collect { |type| "'#{type}'" } [ :error, "Expected attribute: '#{key}' to be a type of: #{types_msg.join(', ')}" ] end end # Validate that the attribute on the given model has a non-nil value assigned # # @param [VariaModel] model # @param [String] key # # @return [Array] def validate_required(model, key) if model.get_attribute(key).nil? [ :error, "A value is required for attribute: '#{key}'" ] else [ :ok, "" ] end end private def register_attribute(name, options = {}) if options[:type] && options[:type].any? unless options[:required] options[:type] << NilClass end register_validation(name, lambda { |object, key| validate_kind_of(options[:type], object, key) }) end if options[:required] register_validation(name, lambda { |object, key| validate_required(object, key) }) end class_eval do new_attributes = Attributes.from_dotted_path(name, options[:default]) self.attributes.merge!(new_attributes) if options[:coerce].is_a?(Proc) register_coercion(name, options[:coerce]) end end end def register_validation(name, fun) self.validations[name] = (self.validations_for(name) << fun) end def register_coercion(name, fun) self.attributes.container(name).set_coercion(name.split('.').last, fun) end def define_mimic_methods(name, options = {}) fun_name = name.split('.').first class_eval do define_method(fun_name) do _attributes_[fun_name] end define_method("#{fun_name}=") do |value| value = if options[:coerce].is_a?(Proc) options[:coerce].call(value) else value end _attributes_[fun_name] = value end end end end class << self def included(base) base.extend(ClassMethods) end end # @return [Hashie::Mash] def validate self.class.validations.each do |attr_path, validations| validations.each do |validation| status, messages = validation.call(self, attr_path) if status == :error if messages.is_a?(Array) messages.each do |message| self.add_error(attr_path, message) end else self.add_error(attr_path, messages) end end end end self.errors end # @return [Boolean] def valid? validate.empty? end # @return [Hashie::Mash] def errors @errors ||= Hashie::Mash.new end # Assigns the attributes of a model from a given hash of attributes. # # If the assignment mode is set to `:whitelist`, then only the values of keys which have a # corresponding attribute definition on the model will be set. All other keys will have their # values ignored. # # If the assignment mode is set to `:carefree`, then the attributes hash will be populated # with any key/values that are provided. # # @param [Hash] new_attrs def mass_assign(new_attrs = {}) case self.class.assignment_mode when :whitelist whitelist_assign(new_attrs) when :carefree carefree_assign(new_attrs) end end # @param [#to_s] key # # @return [Object] def get_attribute(key) eval_as_proc(_attributes_.dig(key.to_s)) end alias_method :[], :get_attribute # @param [#to_s] key # @param [Object] value def set_attribute(key, value) _attributes_.deep_merge!(Attributes.from_dotted_path(key.to_s, value)) end alias_method :[]=, :set_attribute # @param [#to_hash] hash # # @return [self] def from_hash(hash) mass_assign(hash.to_hash) self end # @param [String] data # # @return [self] def from_json(data) mass_assign(JSON.parse(data)) self end # The storage hash containing all of the key/values for this object's attributes # # @return [Hashie::Mash] def _attributes_ @_attributes_ ||= self.class.attributes.dup end alias_method :to_hash, :_attributes_ # @option options [Boolean] :symbolize_keys # @option options [Class, Symbol, String] :adapter # # @return [String] def to_json(options = {}) JSON.generate(to_hash, options) end alias_method :as_json, :to_json # Convert the object to a hash. def to_hash _attributes_.inject({}) { |h, (k,v)| h[k] = eval_as_proc(v); h } end protected # @param [String] attribute # @param [String] message def add_error(attribute, message) self.errors[attribute] ||= Array.new self.errors[attribute] << message end private # Send #call to the given object if it responds to it. If it doesn't, just return the # object. # # @param [#call] def eval_as_proc(obj) obj.respond_to?(:call) ? obj.call : obj end def carefree_assign(new_attrs = {}) _attributes_.deep_merge!(new_attrs) end def whitelist_assign(new_attrs = {}) self.class.attributes.dotted_paths.each do |dotted_path| value = new_attrs.dig(dotted_path) next if value.nil? set_attribute(dotted_path, value) end end end