class LazyMapper

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

Constants

CAMELIZE
DEFAULT_MAPPINGS

Default mappings for built-in types

DEFAULT_VALUES

Default values for built-in value types

TO_BOOL

Converts a value to true or false according to its truthyness

VERSION
WRITER

Attributes

mappers[W]
unmapped_data[W]

Public Class Methods

attributes() click to toggle source
# File lib/lazy_mapper/lazy_mapper.rb, line 41
def self.attributes
  @attributes ||= {}
end
default_value_for(type, value) click to toggle source

Adds (or overrides) a default type for a given type

# File lib/lazy_mapper/lazy_mapper.rb, line 22
def self.default_value_for type, value
  default_values[type] = value
end
default_values() click to toggle source
# File lib/lazy_mapper/lazy_mapper.rb, line 26
def self.default_values
  @default_values ||= DEFAULT_VALUES
end
from(unmapped_data, mappers: {}) click to toggle source

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) })
# File lib/lazy_mapper/lazy_mapper.rb, line 126
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
is(name, from: map_name(name), map: TO_BOOL, default: false) click to toggle source

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
# File lib/lazy_mapper/lazy_mapper.rb, line 213
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
many(name, type, from: map_name(name), **args) click to toggle source

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
# File lib/lazy_mapper/lazy_mapper.rb, line 244
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
map_name(name) click to toggle source

Defines how to map an attribute name to the corresponding name in the unmapped JSON object.

Defaults to CAMELIZE

# File lib/lazy_mapper/lazy_mapper.rb, line 310
def self.map_name(name)
  CAMELIZE[name]
end
mapper_for(type, mapper) click to toggle source

Adds a mapper for a give type

# File lib/lazy_mapper/lazy_mapper.rb, line 33
def self.mapper_for(type, mapper)
  mappers[type] = mapper
end
mappers() click to toggle source
# File lib/lazy_mapper/lazy_mapper.rb, line 37
def self.mappers
  @mappers ||= DEFAULT_MAPPINGS
end
new(values = {}) click to toggle source

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)
  ]
# File lib/lazy_mapper/lazy_mapper.rb, line 90
def initialize(values = {})
  @mappers = {}
  values.each do |name, value|
    send(WRITER[name], value)
  end
end
one(name, type, from: map_name(name), allow_nil: true, **args) click to toggle source

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
# File lib/lazy_mapper/lazy_mapper.rb, line 165
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

Public Instance Methods

add_mapper_for(type, &block) click to toggle source

Adds an instance-level type mapper

# File lib/lazy_mapper/lazy_mapper.rb, line 270
def add_mapper_for(type, &block)
  mappers[type] = block
end
inspect() click to toggle source
# File lib/lazy_mapper/lazy_mapper.rb, line 286
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
mappers() click to toggle source
# File lib/lazy_mapper/lazy_mapper.rb, line 58
def mappers
  @mappers ||= self.class.mappers
end
to_h() click to toggle source

Returns a Hash with keys corresponding to attribute names, and values corresponding to mapped attribute values.

Note: This will eagerly map all attributes that haven't yet been mapped

# File lib/lazy_mapper/lazy_mapper.rb, line 280
def to_h
  attributes.each_with_object({}) { |(key, _value), h|
    h[key] = self.send key
  }
end

Private Instance Methods

attributes() click to toggle source
# File lib/lazy_mapper/lazy_mapper.rb, line 331
def attributes
  self.class.attributes
end
check_type!(value, type, allow_nil: permitted_types = allow_nil ? Array(type) + [ NilClass ] : Array(type)) click to toggle source
# File lib/lazy_mapper/lazy_mapper.rb, line 348
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
default_value(type) click to toggle source
# File lib/lazy_mapper/lazy_mapper.rb, line 327
def default_value(type)
  self.class.default_values[type]
end
humanize_list(terms, separator: ', ', conjunction: ' and ') click to toggle source
1,2,3

-> “1, 2 and 3”

1, 2

-> “1 and 2”

1

-> “1”

# File lib/lazy_mapper/lazy_mapper.rb, line 360
def humanize_list terms, separator: ', ', conjunction: ' and '
  *all_but_last, last = terms
  return last if all_but_last.empty?

  [ all_but_last.join(separator), last ].join conjunction
end
mapped_value(name, unmapped_value, type, map: mapping_for(name, type), default: default_value(type)) click to toggle source
# File lib/lazy_mapper/lazy_mapper.rb, line 335
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
mapping_for(name, type) click to toggle source
# File lib/lazy_mapper/lazy_mapper.rb, line 323
def mapping_for(name, type)
  mappers[name] || mappers[type] || self.class.mappers[type]
end
memoize(name, ivar = IVAR[name]) { || ... } click to toggle source
# File lib/lazy_mapper/lazy_mapper.rb, line 367
def memoize name, ivar = IVAR[name]
  send WRITER[name], yield unless instance_variable_defined?(ivar)
  instance_variable_get(ivar)
end
unmapped_data() click to toggle source
# File lib/lazy_mapper/lazy_mapper.rb, line 319
def unmapped_data
  @unmapped_data ||= {}
end