diff --git a/app/models/preneeds/base.rb b/app/models/preneeds/base.rb index 4c426c03632..660af403bf5 100644 --- a/app/models/preneeds/base.rb +++ b/app/models/preneeds/base.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'vets/model' + # This will be moved after virtus is removed module Boolean; end class TrueClass; include Boolean; end @@ -9,103 +11,7 @@ class FalseClass; include Boolean; end # Should not be initialized directly # module Preneeds - class Base - extend ActiveModel::Naming - include ActiveModel::Model - include ActiveModel::Serializers::JSON - - @attributes = {}.freeze - - class << self - # class variable attributes won't work so this is - # the only way for it to work. Thread safety shouldn't - # matter because @attributes is the same across all thread - # they are set by the class. - # rubocop:disable ThreadSafety/InstanceVariableInClassMethod - def attributes - @attributes ||= {} - end - # rubocop:enable ThreadSafety/InstanceVariableInClassMethod - - # Class method to define a setter & getter for attribute - # this will also coerce a hash to the require class - # doesn't currently coerce scalar classes such as string to int - # In the future this could become it's own class e.g., Vets::Model::Attribute - # - # @param name [Symbol] the name of the attribute - # @param klass [Class] the class of the attribute - # @param default [String|Integer] the default value of the attribute - # - def attribute(name, klass, **options) - default = options[:default] - array = options[:array] || false - - attributes[name] = { type: klass, default:, array: } - - define_getter(name, default) - define_setter(name, klass, array) - end - - def attribute_set - attributes.keys - end - - private - - def define_getter(name, default) - define_method(name) do - instance_variable_get("@#{name}") || begin - return nil unless defined?(default) - - if default.is_a?(Symbol) && respond_to?(default) - send(default) - else - default - end - end - end - end - - def define_setter(name, klass, array) - define_method("#{name}=") do |value| - if array - raise TypeError, "#{name} must be an Array" unless value.is_a?(Array) - - value = value.map do |item| - item.is_a?(Hash) ? klass.new(item) : item - end - - unless value.all? { |item| item.is_a?(klass) } - raise TypeError, "All elements of #{name} must be of type #{klass}" - end - end - - value = ActiveModel::Type::Boolean.new.cast(value) if klass == Boolean - - value = klass.new(value) if value.is_a?(Hash) - - if (array && value.is_a?(Array)) || value.is_a?(klass) || value.nil? - instance_variable_set("@#{name}", value) - else - raise TypeError, "#{name} must be a #{klass}" - end - end - end - end - - def initialize(params = {}) - super - # Ensure all attributes have a defined value (default to nil) - self.class.attribute_set.each do |attr_name| - instance_variable_set("@#{attr_name}", nil) unless instance_variable_defined?("@#{attr_name}") - end - end - - # Acts as ActiveRecord::Base#attributes which is needed for serialization - def attributes - nested_attributes(instance_values) - end - + class Base < Vets::Model # Override `as_json` # # @param options [Hash] @@ -115,22 +21,5 @@ def attributes def as_json(options = {}) super(options).deep_transform_keys { |key| key.camelize(:lower) } end - - private - - # Collect values from attribute and nested objects - # - # @param values [Hash] - # - # @return [Hash] nested attributes - def nested_attributes(values) - values.transform_values do |value| - if value.respond_to?(:instance_values) - nested_attributes(value.instance_values) - else - value - end - end - end end end diff --git a/lib/vets/attributes.rb b/lib/vets/attributes.rb new file mode 100644 index 00000000000..864ede5ce3f --- /dev/null +++ b/lib/vets/attributes.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Vets + module Attributes + def self.included(base) + base.extend(ClassMethods) + base.instance_variable_set(:@attributes, {}) + end + + module ClassMethods + + # rubocop:disable ThreadSafety/InstanceVariableInClassMethod + def attributes + @attributes + end + # rubocop:enable ThreadSafety/InstanceVariableInClassMethod + + def attribute(name, klass, **options) + default = options[:default] + array = options[:array] || false + + attributes[name] = { type: klass, default:, array: } + + define_getter(name, default) + define_setter(name, klass, array) + end + + def attribute_set + attributes.keys + end + + private + + def define_getter(name, default) + define_method(name) do + instance_variable_get("@#{name}") || begin + return nil unless defined?(default) + + if default.is_a?(Symbol) && respond_to?(default) + send(default) + else + default + end + end + end + end + + def define_setter(name, klass, array) + define_method("#{name}=") do |value| + Vets::Attributes::Value.cast(name, klass, value, array) + end + end + end + end +end diff --git a/lib/vets/attributes/value.rb b/lib/vets/attributes/value.rb new file mode 100644 index 00000000000..26cd5567537 --- /dev/null +++ b/lib/vets/attributes/value.rb @@ -0,0 +1,53 @@ +module Vets + module Attributes + class Value + + def self.cast(name, klass, value, array: false) + new((name, klass, value, array)).setter_value(value) + end + + def initialize(name, klass, array: false) + @name = name + @klass_type = klass_type + @array = array + end + + def setter_value(value) + validate_array(value) if @array + value = cast_boolean(value) if @klass_type == Boolean + value = coerce_to_class(value) + validate_type(value) + value + end + + private + + def validate_array(value) + raise TypeError, "#{@name} must be an Array" unless value.is_a?(Array) + + value.map! do |item| + item.is_a?(Hash) ? @klass_type.new(item) : item + end + + unless value.all? { |item| item.is_a?(@klass_type) } + raise TypeError, "All elements of #{@name} must be of type #{@klass_type}" + end + end + + def cast_boolean(value) + ActiveModel::Type::Boolean.new.cast(value) + end + + def coerce_to_class(value) + value.is_a?(Hash) ? @klass_type.new(value) : value + end + + def validate_type(value) + if (@array && value.is_a?(Array)) || value.is_a?(@klass_type) || value.nil? + return + end + raise TypeError, "#{@name} must be a #{@klass_type}" + end + end + end +end diff --git a/lib/vets/model.rb b/lib/vets/model.rb new file mode 100644 index 00000000000..e86e61a8288 --- /dev/null +++ b/lib/vets/model.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Vets + class Model + extend ActiveModel::Naming + include ActiveModel::Model + include ActiveModel::Serializers::JSON + include Vets::Attributes + + def initialize(params = {}) + super + # Ensure all attributes have a defined value (default to nil) + self.class.attribute_set.each do |attr_name| + instance_variable_set("@#{attr_name}", nil) unless instance_variable_defined?("@#{attr_name}") + end + end + + # Acts as ActiveRecord::Base#attributes which is needed for serialization + def attributes + nested_attributes(instance_values) + end + + private + + # Collect values from attribute and nested objects + # + # @param values [Hash] + # + # @return [Hash] nested attributes + def nested_attributes(values) + values.transform_values do |value| + if value.respond_to?(:instance_values) + nested_attributes(value.instance_values) + else + value + end + end + end + end +end