lib/dry/struct/class_interface.rb in dry-struct-0.4.0 vs lib/dry/struct/class_interface.rb in dry-struct-0.5.0

- old
+ new

@@ -1,10 +1,12 @@ require 'dry/core/class_attributes' -require 'dry/equalizer' +require 'dry/core/inflector' +require 'dry/core/descendants_tracker' require 'dry/struct/errors' require 'dry/struct/constructor' +require 'dry/struct/sum' module Dry class Struct # Class-level interface of {Struct} and {Value} module ClassInterface @@ -15,44 +17,111 @@ # @param [Class] klass def inherited(klass) super - klass.equalizer Equalizer.new(*schema.keys) - klass.send(:include, klass.equalizer) + base = self + + klass.class_eval do + @meta = base.meta + + unless equal?(Value) + extend Dry::Core::DescendantsTracker + end + end end # Adds an attribute for this {Struct} with given `name` and `type` # and modifies {.schema} accordingly. # # @param [Symbol] name name of the defined attribute - # @param [Dry::Types::Definition] type + # @param [Dry::Types::Definition, nil] type or superclass of nested type # @return [Dry::Struct] + # @yield + # If a block is given, it will be evaluated in the context of + # a new struct class, and set as a nested type for the given + # attribute. A class with a matching name will also be defined for + # the nested type. # @raise [RepeatedAttributeError] when trying to define attribute with the # same name as previously defined one # - # @example + # @example with nested structs # class Language < Dry::Struct # attribute :name, Types::String + # attribute :details, Dry::Struct do + # attribute :type, Types::String + # end # end # # Language.schema - # #=> {name: #<Dry::Types::Definition primitive=String options={}>} + # #=> { + # :name=>#<Dry::Types::Definition primitive=String options={} meta={}>, + # :details=>Language::Details + # } # - # ruby = Language.new(name: 'Ruby') + # ruby = Language.new(name: 'Ruby', details: { type: 'OO' }) # ruby.name #=> 'Ruby' - def attribute(name, type) + # ruby.details #=> #<Language::Details type="OO"> + # ruby.details.type #=> 'OO' + # + # @example with a nested array of structs + # class Language < Dry::Struct + # attribute :name, Types::String + # array :versions, Types::String + # array :celebrities, Types::Array.of(Dry::Struct) do + # attribute :name, Types::String + # attribute :pseudonym, Types::String + # end + # end + # + # Language.schema + # #=> { + # :name=>#<Dry::Types::Definition primitive=String options={} meta={}>, + # :versions=>#<Dry::Types::Array::Member primitive=Array options={:member=>#<Dry::Types::Definition primitive=String options={} meta={}>} meta={}>, + # :celebrities=>#<Dry::Types::Array::Member primitive=Array options={:member=>Language::Celebrity} meta={}> + # } + # + # ruby = Language.new( + # name: 'Ruby', + # versions: %w(1.8.7 1.9.8 2.0.1), + # celebrities: [ + # { name: 'Yukihiro Matsumoto', pseudonym: 'Matz' }, + # { name: 'Aaron Patterson', pseudonym: 'tenderlove' } + # ] + # ) + # ruby.name #=> 'Ruby' + # ruby.versions #=> ['1.8.7', '1.9.8', '2.0.1'] + # ruby.celebrities + # #=> [ + # #<Language::Celebrity name='Yukihiro Matsumoto' pseudonym='Matz'>, + # #<Language::Celebrity name='Aaron Patterson' pseudonym='tenderlove'> + # ] + # ruby.celebrities[0].name #=> 'Yukihiro Matsumoto' + # ruby.celebrities[0].pseudonym #=> 'Matz' + # ruby.celebrities[1].name #=> 'Aaron Patterson' + # ruby.celebrities[1].pseudonym #=> 'tenderlove' + def attribute(name, type = nil, &block) + if block + type = Dry::Types[type] if type.is_a?(String) + type = struct_builder.(name, type, &block) + elsif type.nil? + raise( + ArgumentError, + 'you must supply a type or a block to `Dry::Struct.attribute`' + ) + end + attributes(name => type) end # @param [Hash{Symbol => Dry::Types::Definition}] new_schema # @return [Dry::Struct] # @raise [RepeatedAttributeError] when trying to define attribute with the # same name as previously defined one # @see #attribute # @example - # class Book1 < Dry::Struct + # class Book < Dry::Struct # attributes( # title: Types::String, # author: Types::String # ) # end @@ -61,22 +130,64 @@ # #=> {title: #<Dry::Types::Definition primitive=String options={}>, # # author: #<Dry::Types::Definition primitive=String options={}>} def attributes(new_schema) check_schema_duplication(new_schema) - schema schema.merge(new_schema) - input Types['coercible.hash'].public_send(constructor_type, schema) + input input.schema(new_schema) new_schema.each_key do |key| - attr_reader(key) unless instance_methods.include?(key) + next if instance_methods.include?(key) + class_eval(<<-RUBY) + def #{ key } + @attributes[#{ key.inspect }] + end + RUBY end - equalizer.instance_variable_get('@keys').concat(new_schema.keys) + @attribute_names = nil + descendants. + select { |d| d.superclass == self }. + each { |d| d.attributes(new_schema.reject { |k, _| d.schema.key?(k) }) } + self end + # Add an arbitrary transformation for new attribute types. + # + # @param [#call,nil] proc + # @param [#call,nil] block + # @example + # class Book < Dry::Struct + # transform_types { |t| t.meta(struct: :Book) } + # + # attribute :title, Types::Strict::String + # end + # + # Book.schema[:title].meta # => { struct: :Book } + # + def transform_types(proc = nil, &block) + input input.with_type_transform(proc || block) + end + + # Add an arbitrary transformation for input hash keys. + # + # @param [#call,nil] proc + # @param [#call,nil] block + # @example + # class Book < Dry::Struct + # transform_keys(&:to_sym) + # + # attribute :title, Types::Strict::String + # end + # + # Book.new('title' => "The Old Man and the Sea") + # # => #<Book title="The Old Man and the Sea"> + def transform_keys(proc = nil, &block) + input input.with_key_transform(proc || block) + end + # @param [Hash{Symbol => Dry::Types::Definition, Dry::Struct}] new_schema # @raise [RepeatedAttributeError] when trying to define attribute with the # same name as previously defined one def check_schema_duplication(new_schema) shared_keys = new_schema.keys & (schema.keys - superclass.schema.keys) @@ -85,11 +196,10 @@ end private :check_schema_duplication # @param [Hash{Symbol => Object},Dry::Struct] attributes # @raise [Struct::Error] if the given attributes don't conform {#schema} - # with given {#constructor_type} def new(attributes = default_attributes) if attributes.instance_of?(self) attributes else super(input[attributes]) @@ -108,58 +218,49 @@ new(attributes) end alias_method :[], :call # @param [#call,nil] constructor - # @param [Hash] options + # @param [Hash] _options # @param [#call,nil] block # @return [Dry::Struct::Constructor] def constructor(constructor = nil, **_options, &block) Struct::Constructor.new(self, fn: constructor || block) end - # Retrieves default attributes from defined {.schema}. - # Used in a {Struct} constructor if no attributes provided to {.new} - # - # @return [Hash{Symbol => Object}] - def default_attributes - check_invalid_schema_keys - schema.each_with_object({}) { |(name, type), result| - result[name] = type.evaluate if type.default? - } - end - - def check_invalid_schema_keys - invalid_keys = schema.select { |name, type| type.instance_of?(String) } - raise ArgumentError, argument_error_msg(invalid_keys.keys) if invalid_keys.any? - end - - def argument_error_msg(keys) - "Invaild argument for #{keys.join(', ')}" - end - - # @param [Hash{Symbol => Object}] input + # @param [Hash{Symbol => Object},Dry::Struct] input # @yieldparam [Dry::Types::Result::Failure] failure # @yieldreturn [Dry::Types::ResultResult] # @return [Dry::Types::Result] def try(input) Types::Result::Success.new(self[input]) rescue Struct::Error => e failure = Types::Result::Failure.new(input, e.message) block_given? ? yield(failure) : failure end + # @param [Hash{Symbol => Object},Dry::Struct] input + # @return [Dry::Types::Result] + # @private + def try_struct(input) + if input.is_a?(self) + Types::Result::Success.new(input) + else + yield + end + end + # @param [({Symbol => Object})] args # @return [Dry::Types::Result::Success] def success(*args) result(Types::Result::Success, *args) end # @param [({Symbol => Object})] args # @return [Dry::Types::Result::Failure] def failure(*args) - result(Types::Result::Failure, *args) + result(::Dry::Types::Result::Failure, *args) end # @param [Class] klass # @param [({Symbol => Object})] args def result(klass, *args) @@ -198,14 +299,68 @@ # @return [Boolean] def attribute?(key) schema.key?(key) end + # @return [Hash{Symbol => Dry::Types::Definition, Dry::Struct}] + def schema + input.member_types + end + # Gets the list of attribute names # # @return [Array<Symbol>] def attribute_names @attribute_names ||= schema.keys end + + # @return [{Symbol => Object}] + def meta(meta = Undefined) + if meta.equal?(Undefined) + @meta + else + Class.new(self) do + @meta = @meta.merge(meta) unless meta.empty? + end + end + end + + # Build a sum type + # @param [Dry::Types::Type] type + # @return [Dry::Types::Sum] + def |(type) + if type.is_a?(Class) && type <= Struct + Struct::Sum.new(self, type) + else + super + end + end + + # Stores an object for building nested struct classes + # @return [StructBuilder] + def struct_builder + @struct_builder ||= StructBuilder.new(self).freeze + end + private :struct_builder + + # Retrieves default attributes from defined {.schema}. + # Used in a {Struct} constructor if no attributes provided to {.new} + # + # @return [Hash{Symbol => Object}] + def default_attributes(default_schema = schema) + default_schema.each_with_object({}) do |(name, type), result| + result[name] = default_attributes(type.schema) if struct?(type) + end + end + private :default_attributes + + # Checks if the given type is a Dry::Struct + # + # @param [Dry::Types::Definition, Dry::Struct] type + # @return [Boolean] + def struct?(type) + type.is_a?(Class) && type <= Struct + end + private :struct? end end end