lib/avromatic/model/attributes.rb in avromatic-1.0.0 vs lib/avromatic/model/attributes.rb in avromatic-2.0.0
- old
+ new
@@ -1,9 +1,9 @@
+# frozen_string_literal: true
+
require 'active_support/core_ext/object/duplicable'
require 'active_support/time'
-require 'ice_nine/core_ext/object'
-require 'avromatic/model/allowed_type_validator'
module Avromatic
module Model
# This module supports defining Virtus attributes for a model based on the
@@ -18,20 +18,100 @@
@field = field
super("Optional field not allowed: #{field}")
end
end
- def self.first_union_schema(field_type)
- # TODO: This is a hack until I find a better solution for unions with
- # Virtus. This only handles a union for an optional field with :null
- # and one other type.
- # This hack lives on for now because custom type coercion is not pushed
- # down into unions. This means that custom types can only be optional
- # fields, not members of real unions.
- field_type.schemas.reject { |schema| schema.type_sym == :null }.first
+ class AttributeDefinition
+ attr_reader :name, :type, :field, :default, :owner
+ delegate :serialize, to: :type
+
+ def initialize(owner:, field:, type:)
+ @owner = owner
+ @field = field
+ @type = type
+ @name = field.name.to_sym
+ @default = if field.default == :no_default
+ nil
+ elsif field.default.duplicable?
+ field.default.dup.deep_freeze
+ else
+ field.default
+ end
+ end
+
+ def required?
+ FieldHelper.required?(field)
+ end
+
+ def coerce(input)
+ type.coerce(input)
+ rescue Avromatic::Model::UnknownAttributeError => e
+ raise Avromatic::Model::CoercionError.new("Value for #{owner.name}##{name} could not be coerced to a #{type.name} " \
+ "because the following unexpected attributes were provided: #{e.unknown_attributes.join(', ')}. " \
+ "Only the following attributes are allowed: #{e.allowed_attributes.join(', ')}. " \
+ "Provided argument: #{input.inspect}")
+ rescue StandardError
+ if type.input_classes && type.input_classes.none? { |input_class| input.is_a?(input_class) }
+ raise Avromatic::Model::CoercionError.new("Value for #{owner.name}##{name} could not be coerced to a #{type.name} " \
+ "because a #{input.class.name} was provided but expected a #{type.input_classes.map(&:name).to_sentence(two_words_connector: ' or ', last_word_connector: ', or ')}. " \
+ "Provided argument: #{input.inspect}")
+ elsif input.is_a?(Hash) && type.is_a?(Avromatic::Model::Types::UnionType)
+ raise Avromatic::Model::CoercionError.new("Value for #{owner.name}##{name} could not be coerced to a #{type.name} " \
+ "because no union member type matches the provided attributes: #{input.inspect}")
+ else
+ raise Avromatic::Model::CoercionError.new("Value for #{owner.name}##{name} could not be coerced to a #{type.name}. " \
+ "Provided argument: #{input.inspect}")
+ end
+ end
end
+ included do
+ class_attribute :attribute_definitions, instance_writer: false
+ self.attribute_definitions = {}
+ end
+
+ def initialize(data = {})
+ super()
+
+ valid_keys = []
+ attribute_definitions.each do |attribute_name, attribute_definition|
+ if data.include?(attribute_name)
+ valid_keys << attribute_name
+ value = data.fetch(attribute_name)
+ _attributes[attribute_name] = attribute_definition.coerce(value)
+ elsif data.include?(attribute_name.to_s)
+ valid_keys << attribute_name
+ value = data[attribute_name.to_s]
+ _attributes[attribute_name] = attribute_definition.coerce(value)
+ elsif !attributes.include?(attribute_name)
+ _attributes[attribute_name] = attribute_definition.default
+ end
+ end
+
+ unless Avromatic.allow_unknown_attributes || valid_keys.size == data.size
+ unknown_attributes = (data.keys.map(&:to_s) - valid_keys.map(&:to_s)).sort
+ allowed_attributes = attribute_definitions.keys.map(&:to_s).sort
+ message = "Unexpected arguments for #{self.class.name}#initialize: #{unknown_attributes.join(', ')}. " \
+ "Only the following arguments are allowed: #{allowed_attributes.join(', ')}. Provided arguments: #{data.inspect}"
+ raise Avromatic::Model::UnknownAttributeError.new(message, unknown_attributes: unknown_attributes,
+ allowed_attributes: allowed_attributes)
+ end
+ end
+
+ def to_h
+ _attributes.dup
+ end
+
+ alias_method :to_hash, :to_h
+ alias_method :attributes, :to_h
+
+ private
+
+ def _attributes
+ @attributes ||= {}
+ end
+
module ClassMethods
def add_avro_fields
# models are registered in Avromatic.nested_models at this point to
# ensure that they are available as fields for recursive models.
register!
@@ -75,168 +155,42 @@
if schema.type_sym != :record
raise "Unsupported schema type '#{schema.type_sym}', only 'record' schemas are supported."
end
schema.fields.each do |field|
- raise OptionalFieldError.new(field) if !allow_optional && optional?(field)
+ raise OptionalFieldError.new(field) if !allow_optional && FieldHelper.optional?(field)
- field_class = avro_field_class(field.type)
+ symbolized_field_name = field.name.to_sym
+ attribute_definition = AttributeDefinition.new(
+ owner: self,
+ field: field,
+ type: create_type(field)
+ )
+ attribute_definitions[symbolized_field_name] = attribute_definition
- attribute(field.name,
- field_class,
- avro_field_options(field, field_class))
+ define_method(field.name) { _attributes[symbolized_field_name] }
+ define_method("#{field.name}?") { !!_attributes[symbolized_field_name] } if boolean?(field)
- add_validation(field, field_class)
- add_serializer(field, field_class)
- end
- end
+ define_method("#{field.name}=") do |value|
+ _attributes[symbolized_field_name] = attribute_definitions[symbolized_field_name].coerce(value)
+ end
- def add_validation(field, field_class)
- case field.type.type_sym
- when :enum
- validates(field.name,
- inclusion: { in: Set.new(field.type.symbols.map(&:freeze)).freeze })
- when :fixed
- validates(field.name, length: { is: field.type.size })
- when :record, :array, :map, :union
- validate_complex(field.name)
- else
- add_type_validation(field.name, field_class)
- end
-
- add_required_validation(field)
- end
-
- def add_type_validation(name, field_class)
- allowed_types = if field_class == Axiom::Types::Boolean
- [TrueClass, FalseClass]
- elsif field_class < Avromatic::Model::Attribute::AbstractTimestamp
- [Time]
- else
- [field_class]
- end
-
- validates(name, allowed_type: allowed_types, allow_blank: true)
- end
-
- def add_required_validation(field)
- if required?(field) && field.default == :no_default
- case field.type.type_sym
- when :array, :map, :boolean
- validates(field.name, exclusion: { in: [nil], message: "can't be nil" })
- else
- validates(field.name, presence: true)
+ unless config.mutable # rubocop:disable Style/Next
+ private("#{field.name}=")
+ define_method(:clone) { self }
+ define_method(:dup) { self }
end
end
end
- # An optional field is represented as a union where the first member
- # is null.
- def optional?(field)
- field.type.type_sym == :union &&
- field.type.schemas.first.type_sym == :null
+ def boolean?(field)
+ field.type.type_sym == :boolean ||
+ (FieldHelper.optional?(field) && field.type.schemas.last.type_sym == :boolean)
end
- def required?(field)
- !optional?(field)
+ def create_type(field)
+ Avromatic::Model::Types::TypeFactory.create(schema: field.type, nested_models: nested_models)
end
-
- def avro_field_class(field_type)
- custom_type = Avromatic.type_registry.fetch(field_type)
- return custom_type.value_class if custom_type.value_class
-
- if field_type.respond_to?(:logical_type)
- value_class = Avromatic::Model::LogicalTypes.value_class(field_type.logical_type)
- return value_class if value_class
- end
-
- case field_type.type_sym
- when :string, :bytes, :fixed
- String
- when :boolean
- Axiom::Types::Boolean
- when :int, :long
- Integer
- when :float, :double
- Float
- when :enum
- String
- when :null
- NilClass
- when :array
- Array[avro_field_class(field_type.items)]
- when :map
- Hash[String => avro_field_class(field_type.values)]
- when :union
- union_field_class(field_type)
- when :record
- build_nested_model(field_type)
- else
- raise "Unsupported type #{field_type}"
- end
- end
-
- def union_field_class(field_type)
- null_index = field_type.schemas.index { |schema| schema.type_sym == :null }
- raise 'a null type in a union must be the first member' if null_index && null_index > 0
-
- field_classes = field_type.schemas.reject { |schema| schema.type_sym == :null }
- .map { |schema| avro_field_class(schema) }
-
- if field_classes.size == 1
- field_classes.first
- else
- Avromatic::Model::AttributeType::Union[*field_classes]
- end
- end
-
- def avro_field_options(field, field_class)
- options = {}
-
- prevent_union_including_custom_type!(field, field_class)
-
- custom_type = Avromatic.type_registry.fetch(field, field_class)
- coercer = custom_type.deserializer
- options[:coercer] = coercer if coercer
-
- # See: https://github.com/dasch/avro_turf/pull/36
- if field.default != :no_default
- options.merge!(default: default_for(field.default), lazy: true)
- end
-
- options
- end
-
- def add_serializer(field, field_class)
- prevent_union_including_custom_type!(field, field_class)
-
- custom_type = Avromatic.type_registry.fetch(field, field_class)
- serializer = custom_type.serializer
-
- avro_serializer[field.name.to_sym] = serializer if serializer
- end
-
- def default_for(value)
- value.duplicable? ? value.dup.deep_freeze : value
- end
-
- # TODO: the methods below are temporary until support for custom types
- # as union members are supported.
- def member_uses_custom_type?(field)
- field.type.schemas.any? do |klass|
- Avromatic.type_registry.fetch(klass) != NullCustomType
- end
- end
-
- def prevent_union_including_custom_type!(field, field_class)
- if field_class.is_a?(Class) &&
- field_class < Avromatic::Model::AttributeType::Union &&
- member_uses_custom_type?(field)
-
- raise 'custom types within unions are currently unsupported'
- end
- end
-
end
end
end
end