lib/lazy_mapper.rb in lazy_mapper-0.3.2 vs lib/lazy_mapper.rb in lazy_mapper-0.4.0

- old
+ new

@@ -1,403 +1,8 @@ # frozen_string_literal: true -require 'bigdecimal' -require 'bigdecimal/util' -require 'time' - -# -# Wraps a Hash or Hash-like data structure of primitive values and lazily maps -# its attributes to semantically rich domain objects using either a set of -# default mappers (for Ruby's built-in value types), or custom mappers which -# can be added either at the class level or at the instance level. -# -# Example: -# class Foo < LazyMapper -# one :id, Integer, from: 'xmlId' -# one :created_at, Time -# one :amount, Money, map: Money.method(:parse) -# many :users, User, map: ->(u) { User.new(u) } -# end -# class LazyMapper - - # - # Default mappings for built-in types - # - DEFAULT_MAPPINGS = { - Object => :itself.to_proc, - String => :to_s.to_proc, - Integer => :to_i.to_proc, - BigDecimal => :to_d.to_proc, - Float => :to_f.to_proc, - Symbol => :to_sym.to_proc, - Hash => :to_h.to_proc, - Time => Time.method(:iso8601), - Date => Date.method(:parse), - URI => URI.method(:parse) - }.freeze - - # - # Adds (or overrides) a default type for a given type - # - def self.default_value_for type, value - default_values[type] = value - end - - def self.default_values - @default_values ||= DEFAULT_VALUES - end - - # - # Default values for built-in value types - # - DEFAULT_VALUES = { - String => '', - Integer => 0, - Numeric => 0, - Float => 0.0, - BigDecimal => BigDecimal(0), - Array => [] - }.freeze - - # - # Adds a mapper for a give type - # - def self.mapper_for(type, mapper) - mappers[type] = mapper - end - - def self.mappers - @mappers ||= DEFAULT_MAPPINGS - end - - def self.attributes - @attributes ||= {} - end - - def self.inherited klass # :nodoc: - # Make the subclass "inherit" the values of these class instance variables - %i[ - mappers - default_values - attributes - ].each do |s| - klass.instance_variable_set IVAR[s], self.send(s).dup - end - end - - def mappers - @mappers ||= self.class.mappers - end - - IVAR = lambda { |name| # :nodoc: - name_as_str = name.to_s - name_as_str = name_as_str[0...-1] if name_as_str[-1] == '?' - - ('@' + name_as_str).freeze - } - - WRITER = -> name { (name.to_s.delete('?') + '=').to_sym } - - # - # Creates a new instance by giving a Hash of attribues. - # - # Attribute values are type checked according to how they were defined. - # - # Fails with +TypeError+, if a value doesn't have the expected type. - # - # == Example - # - # Foo.new :id => 42, - # :created_at => Time.parse("2015-07-29 14:07:35 +0200"), - # :amount => Money.parse("$2.00"), - # :users => [ - # User.new("id" => 23, "name" => "Adam"), - # User.new("id" => 45, "name" => "Ole"), - # User.new("id" => 66, "name" => "Anders"), - # User.new("id" => 91, "name" => "Kristoffer) - # ] - - def initialize(values = {}) - @mappers = {} - values.each do |name, value| - send(WRITER[name], value) - end - end - - # - # Create a new instance by giving a Hash of unmapped attributes. - # - # The keys in the Hash are assumed to be camelCased strings. - # - # == Arguments - # - # +unmapped_data+ - The unmapped data as a Hash(-like object). Must respond to #to_h. - # Keys are assumed to be camelCased string - # - # +mappers:+ - Optional instance-level mappers. - # Keys can either be classes or symbols corresponding to named attributes. - # - # - # == Example - # - # Foo.from({ - # "xmlId" => 42, - # "createdAt" => "2015-07-29 14:07:35 +0200", - # "amount" => "$2.00", - # "users" => [ - # { "id" => 23, "name" => "Adam" }, - # { "id" => 45, "name" => "Ole" }, - # { "id" => 66, "name" => "Anders" }, - # { "id" => 91, "name" => "Kristoffer" } ]}, - # mappers: { - # :amount => -> x { Money.new(x) }, - # User => User.method(:new) }) - # - def self.from unmapped_data, mappers: {} - return nil if unmapped_data.nil? - fail TypeError, "#{ unmapped_data.inspect } is not a Hash" unless unmapped_data.respond_to? :to_h - - instance = new - instance.send :unmapped_data=, unmapped_data.to_h - instance.send :mappers=, mappers - instance - end - - def self.from_json *args, &block # :nodoc: - warn "#{ self }.from_json is deprecated. Use #{ self }.from instead." - from(*args, &block) - end - - # - # Defines an attribute and creates a reader and a writer for it. - # The writer verifies the type of it's supplied value. - # - # == Arguments - # - # +name+ - The name of the attribue - # - # +type+ - The type of the attribute. If the wrapped value is already of that type, the mapper is bypassed. - # If the type is allowed be one of several, use an Array to to specify which ones - # - # +from:+ - Specifies the name of the wrapped value in the JSON object. Defaults to camelCased version of +name+. - # - # +map:+ - Specifies a custom mapper to apply to the wrapped value. - # If unspecified, it defaults to the default mapper for the specified +type+ or simply the identity mapper - # if no default mapper exists. - # - # +default:+ - The default value to use, if the wrapped value is not present in the wrapped JSON object. - # - # +allow_nil:+ - If true, allows the mapped value to be nil. Defaults to true. - # - # == Example - # - # class Foo < LazyMapper - # one :boss, Person, from: "supervisor", map: ->(p) { Person.new(p) } - # one :weapon, [BladedWeapon, Firearm], default: Sixshooter.new - # # ... - # end - # - def self.one(name, type, from: map_name(name), allow_nil: true, **args) - - ivar = IVAR[name] - - # Define writer - define_method(WRITER[name]) { |val| - check_type! val, type, allow_nil: allow_nil - instance_variable_set(ivar, val) - } - - # Define reader - define_method(name) { - memoize(name, ivar) { - unmapped_value = unmapped_data[from] - mapped_value(name, unmapped_value, type, **args) - } - } - - attributes[name] = type - end - - # - # Converts a value to +true+ or +false+ according to its truthyness - # - TO_BOOL = -> b { !!b } - - # - # Defines an boolean attribute - # - # == Arguments - # - # +name+ - The name of the attribue - # - # +from:+ - Specifies the name of the wrapped value in the JSON object. - # Defaults to camelCased version of +name+. - # - # +map:+ - Specifies a custom mapper to apply to the wrapped value. Must be a Callable. - # Defaults to TO_BOOL if unspecified. - # - # +default:+ The default value to use if the value is missing. False, if unspecified - # - # == Example - # - # class Foo < LazyMapper - # is :green?, from: "isGreen", map: ->(x) { !x.zero? } - # # ... - # end - # - def self.is name, from: map_name(name), map: TO_BOOL, default: false - one name, [TrueClass, FalseClass], from: from, allow_nil: false, map: map, default: default - end - - singleton_class.send(:alias_method, :has, :is) - - # - # Defines a collection attribute - # - # == Arguments - # - # +name+ - The name of the attribute - # - # +type+ - The type of the elements in the collection. - # - # +from:+ - Specifies the name of the wrapped array in the unmapped data. - # Defaults to camelCased version of +name+. - # - # +map:+ - Specifies a custom mapper to apply to each elements in the wrapped collection. - # If unspecified, it defaults to the default mapper for the specified +type+ or simply the identity mapper - # if no default mapper exists. - # - # +default:+ - The default value to use, if the unmapped value is missing. - # - # == Example - # - # class Bar < LazyMapper - # many :underlings, Person, from: "serfs", map: ->(p) { Person.new(p) } - # # ... - # end - # - def self.many(name, type, from: map_name(name), **args) - - # Define setter - define_method(WRITER[name]) { |val| - check_type! val, Enumerable, allow_nil: false - instance_variable_set(IVAR[name], val) - } - - # Define getter - define_method(name) { - memoize(name) { - unmapped_value = unmapped_data[from] - if unmapped_value.is_a? Array - unmapped_value.map { |v| mapped_value(name, v, type, **args) } - else - mapped_value name, unmapped_value, Array, **args - end - } - } - - attributes[name] = Array - end - - # - # Adds an instance-level type mapper - # - def add_mapper_for(type, &block) - mappers[type] = block - end - - def to_h - attributes.each_with_object({}) { |(key, _value), h| - h[key] = self.send key - } - end - - def inspect - @__under_inspection__ ||= 0 - return "<#{ self.class.name } ... >" if @__under_inspection__.positive? - - @__under_inspection__ += 1 - present_attributes = attributes.keys.each_with_object({}) { |name, memo| - ivar = IVAR[name] - next unless self.instance_variable_defined? ivar - - memo[name] = self.instance_variable_get ivar - } - - res = "<#{ self.class.name } #{ present_attributes.map { |k, v| k.to_s + ': ' + v.inspect }.join(', ') } >" - @__under_inspection__ -= 1 - res - end - - # - # Defines how to map an attribute name - # to the corresponding name in the unmapped - # JSON object. - # - # Defaults to CAMELIZE - # - def self.map_name(name) - CAMELIZE[name] - end - - private - - attr_writer :unmapped_data - attr_writer :mappers - - def unmapped_data - @unmapped_data ||= {} - end - - def mapping_for(name, type) - mappers[name] || mappers[type] || self.class.mappers[type] - end - - def default_value(type) - self.class.default_values[type] - end - - def attributes - self.class.attributes - end - - def mapped_value(name, unmapped_value, type, map: mapping_for(name, type), default: default_value(type)) - return default.dup if unmapped_value.nil? # Duplicate to prevent accidental sharing between instances - - if map.nil? - fail ArgumentError, "missing mapper for #{ name } (#{ type }). "\ - "Unmapped value: #{ unmapped_value.inspect }" - end - - return map.call(unmapped_value, self) if map.arity > 1 - - map.call(unmapped_value) - end - - def check_type! value, type, allow_nil: - permitted_types = allow_nil ? Array(type) + [ NilClass ] : Array(type) - return if permitted_types.any? value.method(:is_a?) - - fail TypeError.new "#{ self.class.name }: "\ - "#{ value.inspect } is a #{ value.class } "\ - "but was supposed to be a #{ humanize_list permitted_types, conjunction: ' or ' }" - end - - # [1,2,3] -> "1, 2 and 3" - # [1, 2] -> "1 and 2" - # [1] -> "1" - def humanize_list list, separator: ', ', conjunction: ' and ' - *all_but_last, last = list - return last if all_but_last.empty? - - [ all_but_last.join(separator), last ].join conjunction - end - - def memoize name, ivar = IVAR[name] - send WRITER[name], yield unless instance_variable_defined?(ivar) - instance_variable_get(ivar) - end - - SNAKE_CASE_PATTERN = /(_[a-z])/ # :nodoc: - CAMELIZE = -> name { name.to_s.gsub(SNAKE_CASE_PATTERN) { |x| x[1].upcase }.delete('?') } end + +require 'lazy_mapper/version' +require 'lazy_mapper/defaults' +require 'lazy_mapper/lazy_mapper'