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