contactually/zuora-ruby

View on GitHub
lib/utils/schema_model.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

require 'byebug'

module SchemaModel
  def self.included(base)
    base.include InstanceMethods
    base.extend ClassMethods
  end

  module ClassMethods
    # Dynamically configures accessors, dirty tracking, validation,
    # and serialization methods given definition in opts
    # @param [Object] _name - name of schema
    # @param [Hash] opts - See below
    #
    #  class AwesomeClass
    #    schema :my_schema,
    #      id: {
    #       type: Numeric,             # value will be checked using is_a?
    #       valid: -> (v) { v > 0 },   # value will be validated by calling this
    #       schema: [ChildClass]       # single or collection recursive checks
    #       doc: 'Id, number greater than 1'  # documentation string
    #      }
    #  end
    #
    #  a = AwesomeClass.new(id: 1)
    #  a.valid? => true
    #  a.errors => {}
    #
    def schema(_name, opts = {})
      define_method(:definition) { opts }

      opts.each do |k, definition|
        # Reader
        attr_reader k

        # Writer
        define_writer! k, definition
      end
    end

    private

    # Helper for dynamically defining writer method
    # @param [Symbol] k - name of attribute
    # @param [Hash] definition - See docstring for schema above
    def define_writer!(k, definition)
      define_method("#{k}=") do |value|
        # Recursively convert hash and array of hash to schematized objects
        value = ensure_schema value, definition[:schema]

        # Initial value
        instance_variable_set "@#{k}", value

        # Dirty tracking
        self.changed_attributes ||= Set.new
        self.changed_attributes << k
      end
    end
  end

  module InstanceMethods
    attr_accessor :changed_attributes

    def initialize(attrs = {})
      attrs.each do |attr, v|
        send("#{attr}=", v)
      end
    end

    def errors
      check definition, self
    end

    def valid?
      errors.empty?
    end

    def to_json
      return nil unless changed_attributes
      Hash[
        changed_attributes.map { |attr| serialize_attr(attr) }
      ]
    end

    private

    # @param [Symbol] attr
    # @return [Array]
    def serialize_attr(attr)
      value = send(attr)
      value = if value.is_a?(Hash) || value.is_a?(SchemaModel)
                value.to_json
              elsif value.is_a?(Array)
                value.map(&:to_json)
              else
                value
              end

      [attr.to_s.camelize(:lower), value]
    end

    # Given a schema and a value which may be a single record or collection,
    # collect and return any errors.
    # @param [SchemaModel] child_schema - A schema object class
    # @param [Object] value - Array of models or single model
    # @return [Object] Array of errors hashes, or one hash.
    #                   Structure matches 'value' input
    def check_children(child_schema, value)
      return unless child_schema && value.present?

      if value.is_a? Array
        value.map(&:errors).reject(&:empty?)
      else
        value.errors
      end
    end

    # Checks that value is of correct type
    # @param [Maybe Class] type - type to check using value.is_a?(type)
    # @param [Object] value - value to check
    # @return [Maybe String] error message
    def check_type(type, value)
      return unless type && value && !value.is_a?(type)

      "should be of type #{type} but is of type #{value.class}"
    end

    # Checks that required field meets validation
    # @param [Boolean or Callable] valid - callable validation fn or boolean
    #                               function will be called with value
    # @param [Object] value - value to check
    # @return [Maybe String] error message
    def check_validation(valid, value)
      return unless valid && value

      passes_validation = begin
                            valid.call(value)
                          rescue StandardError
                            false
                          end
      passes_validation ? nil : 'is invalid'
    end

    # Mutates errors, adding in error messages scoped to the attribute and key
    # @param [Maybe Hash] errors -
    # @param [Symbol] attr - name of attribute under check
    # @param [Symbol] key - name of validation step
    # @param [Object] val - data to append
    def append!(errors, attr, key, val)
      return unless val.present?

      errors ||= {}
      errors[attr] ||= {}
      errors[attr][key] = val
    end

    # @param [Hash] schema
    # @param [Hash|Object] data
    # @return [Hash]
    def check(schema, data)
      schema.reduce({}) do |errors, (attr, defn)|
        # Destructuring
        child_schema, type = defn.values_at :schema, :type

        # Get the value for this attribute
        value = data.send attr

        # Add error messages
        append! errors, attr, :child, check_children(child_schema, value)
        append! errors, attr, :type, check_type(type, value)
        errors
      end
    end

    # Constructs new instance(s) of provided Schema model from hash or
    # array of hash values. Allows for modeling of has_one and has_many.
    # @param [Array of Hashes or SchemaModels] value
    # @param [SchemaModel] child_schema

    def ensure_schema(value, child_schema)
      if value.present? && child_schema.present?
        value = if child_schema.is_a?(Array)
                  value.map do |item|
                    item.is_a?(SchemaModel) ? item : child_schema[0].new(item)
                  end
                else
                  value.is_a?(SchemaModel) ? value : child_schema.new(value)
                end
      end

      value
    end
  end
end