lib/lazy_mapper.rb in lazy_mapper-0.2.1 vs lib/lazy_mapper.rb in lazy_mapper-0.3.0

- old
+ new

@@ -1,25 +1,23 @@ require 'bigdecimal' require 'bigdecimal/util' require 'time' # -# Wraps a JSON object and lazily maps its attributes to domain objects -# using either a set of default mappers (for Ruby's built-in types), or -# custom mappers specified by the client. +# 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. # -# The mapped values are memoized. -# # 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 # @@ -68,37 +66,45 @@ def self.mappers @mappers ||= DEFAULT_MAPPINGS end + def self.attributes + @attributes ||= {} + end + def self.inherited(klass) - klass.instance_variable_set IVAR[:mappers], self.mappers.dup - klass.instance_variable_set IVAR[:default_values], self.default_values.dup + # 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 = -> name { + IVAR = lambda { |name| # :nodoc: name_as_str = name.to_s - if name_as_str[-1] == '?' - name_as_str = name_as_str[0...-1] - end + name_as_str = name_as_str[0...-1] if name_as_str[-1] == '?' ('@' + name_as_str).freeze } - WRITER = -> name { (name.to_s.gsub('?', '') + '=').to_sym } + 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. - # If a value has the wrong type, a `TypeError` is raised. # + # 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"), @@ -108,11 +114,10 @@ # User.new("id" => 66, "name" => "Anders"), # User.new("id" => 91, "name" => "Kristoffer) # ] def initialize(values = {}) - @json = {} @mappers = {} values.each do |name, value| send(WRITER[name], value) end end @@ -122,20 +127,20 @@ # # The keys in the Hash are assumed to be camelCased strings. # # == Arguments # - # +json+ - The unmapped data as a Hash(-like object). Must respond to #to_h. + # +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_json({ + # Foo.from({ # "xmlId" => 42, # "createdAt" => "2015-07-29 14:07:35 +0200", # "amount" => "$2.00", # "users" => [ # { "id" => 23, "name" => "Adam" }, @@ -144,21 +149,22 @@ # { "id" => 91, "name" => "Kristoffer" } ]}, # mappers: { # :amount => -> x { Money.new(x) }, # User => User.method(:new) }) # - def self.from_json json, mappers: {} - return nil if json.nil? - fail TypeError, "#{ json.inspect } is not a Hash" unless json.respond_to? :to_h + 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 :json=, json.to_h + instance.send :unmapped_data=, unmapped_data.to_h instance.send :mappers=, mappers instance end - def self.attributes - @attributes ||= {} + def self.from_json *args, &block + 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. @@ -199,20 +205,20 @@ } # Define reader define_method(name) { memoize(name, ivar) { - unmapped_value = json[from] + 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 + # Converts a value to +true+ or +false+ according to its truthyness # TO_BOOL = -> b { !!b } # # Defines an boolean attribute @@ -249,19 +255,18 @@ # # +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 JSON object. + # +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 wrapped value is not present - # in the wrapped JSON object. + # +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) } @@ -277,11 +282,11 @@ } # Define getter define_method(name) { memoize(name) { - unmapped_value = json[from] + 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 @@ -294,34 +299,32 @@ # 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__ > 0 @__under_inspection__ += 1 - attributes = self.class.attributes - if self.class.superclass.respond_to? :attributes - attributes = self.class.superclass.attributes.merge attributes - end - present_attributes = attributes.keys.each_with_object({}) {|name, memo| + present_attributes = attributes.keys.each_with_object({}) { |name, memo| value = self.send name memo[name] = value unless value.nil? } - "<#{ self.class.name } #{ present_attributes.map {|k,v| k.to_s + ': ' + v.inspect }.join(', ') } >" - res = "<#{ self.class.name } #{ present_attributes.map {|k,v| k.to_s + ': ' + v.inspect }.join(', ') } >" + "<#{ self.class.name } #{ present_attributes.map { |k, v| k.to_s + ': ' + v.inspect }.join(', ') } >" + res = "<#{ self.class.name } #{ present_attributes.map { |k, v| k.to_s + ': ' + v.inspect }.join(', ') } >" @__under_inspection__ -= 1 res end protected - def json - @json ||= {} - end - # # Defines how to map an attribute name # to the corresponding name in the unmapped # JSON object. # @@ -331,21 +334,29 @@ CAMELIZE[name] end private - attr_writer :json + 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)) if unmapped_value.nil? # Duplicate to prevent accidental sharing between instances default.dup else @@ -367,14 +378,13 @@ *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 }.gsub('?', '') } + CAMELIZE = -> name { name.to_s.gsub(SNAKE_CASE_PATTERN) { |x| x[1].upcase }.delete('?') } end