# frozen_string_literal: true module PriceHubble module EntityConcern # An ActiveRecord-like attribute management feature, with the exception # that the attributes are not generated through a schema file, but are # defined inline the entity class. # module Attributes extend ActiveSupport::Concern included do # Include all custom typed attribute helpers include Attributes::DateArray include Attributes::Enum include Attributes::Range include Attributes::StringInquirer # Collect all registed attribute names as symbols class_attribute :attribute_names # Export all attributes as data hash, as requested by the ActiveModel # API. # # @param sanitize [Boolean] whenever to sanitize the data for transport # @return [Hash{String => Mixed}] the attribute data # # rubocop:disable Metrics/MethodLength because of the # key/value sanitization handling def attributes(sanitize: false) attribute_names.each_with_object({}) do |key, memo| reader = key if sanitize sanitizer = "sanitize_attr_#{key}".to_sym reader = methods.include?(sanitizer) ? sanitizer : key key_sanitizer = "sanitize_attr_key_#{key}".to_sym key = methods.include?(key_sanitizer) ? send(key_sanitizer) : key end result = resolve_attributes(send(reader)) memo[key.to_s] = result end end # rubocop:enable Metrics/MethodLength # A wrapper for the +ActiveModel#assign_attributes+ method with support # for unmapped attributes. These attributes are put into the # +_unmapped+ struct and all the known attributes are assigned like # normal. This allows the client to be forward compatible with changing # APIs. # # @param struct [Hash{Mixed => Mixed}, RecursiveOpenStruct] the data # to assign # @return [Mixed] the input data which was assigned def assign_attributes(struct = {}) # Build a RecursiveOpenStruct and a simple hash from the given data struct, hash = sanitize_data(struct) # Initialize associations and map them accordingly struct, hash = initialize_associations(struct, hash) # Initialize attributes and map unknown ones and pass back the known known = initialize_attributes(struct, hash) # Mass assign the known attributes via ActiveModel super(known) end private # Resolve the attributes for the given object while respecting # sanitization and deep arrays. # # @param obj [Mixed] the object to resolve its attributes # @param sanitize [Boolean] whenever to sanitize the data for transport # @return [Mixed] the attribute(s) data # # rubocop:disable Metrics/MethodLength because thats the pure minimum def resolve_attributes(obj, sanitize: false) if obj.respond_to? :attributes obj = if obj.method(:attributes).arity == 1 obj.attributes(sanitize: sanitize) else obj.attributes end end if obj.is_a? Array obj = obj.map do |elem| resolve_attributes(elem, sanitize: sanitize) end end obj end # rubocop:enable Metrics/MethodLength # Explicitly convert the given struct to an +RecursiveOpenStruct+ and a # deep symbolized key copy for further usage. # # @param data [Hash{Mixed => Mixed}, RecursiveOpenStruct] the initial # data # @return [Array Mixed}>] the # left over data def sanitize_data(data = {}) # Convert the given arguments to a recursive open struct, # when not already done data = ::RecursiveOpenStruct.new(data, recurse_over_arrays: true) \ unless data.is_a? ::RecursiveOpenStruct # Symbolize all keys in deep (including hashes in arrays), while # converting back to an ordinary hash [data, data.to_h] end # Process the given data by separating the known from the unknown # attributes. The unknown attributes are collected on the +_unmapped+ # variable for later access. This allows the entities to be # forward-compatible on the application HTTP responses in case of # additions. # # @param struct [RecursiveOpenStruct] all the data as struct # @param hash [Hash{Symbol => Mixed}] all the data as hash # @return [Hash{Symbol => Mixed}] the known attributes def initialize_attributes(struct, hash) # Substract known keys, to move them to the +_unmapped+ variable attribute_names.each { |key| struct.delete_field(key) } # Merge the previous unmapped struct and the given data self._unmapped = \ ::RecursiveOpenStruct.new(_unmapped.to_h.merge(struct.to_h), recurse_over_arrays: true) # Allow mass assignment of known attributes hash.slice(*attribute_names) end end class_methods do # Initialize the attributes structures on an inherited class. # # @param child_class [Class] the child class which inherits us def inherited_setup_attributes(child_class) child_class.attribute_names = [] end # Register tracked attributes of the entity. This adds the attributes # to the +Class.attribute_names+ collection, generates getters and # setters as well sets up the dirty-tracking of these attributes. # # @param args [Array] the attributes to register def tracked_attr(*args) # Register the attribute names, for easy access self.attribute_names += args # Define getters/setters attr_reader(*args) args.each do |arg| class_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{arg}=(value) #{arg}_will_change! @#{arg} = value end RUBY end # Register the attributes for ActiveModel define_attribute_methods(*args) end # (Re-)Register attributes with strict type casts. This adds additional # reader methods as well as a writer with casted type. # # @param name [Symbol, String] the name of the attribute # @param type [Symbol] the type of the attribute # @param args [Hash{Symbol => Mixed}] additional options for the type def typed_attr(name, type, **args) send("typed_attr_#{type}", name, **args) end end end end end