diff --git a/lib/mapper.rb b/lib/mapper.rb index f6ade1d..7ddcd09 100644 --- a/lib/mapper.rb +++ b/lib/mapper.rb @@ -3,6 +3,7 @@ require_relative 'mapper/version' require_relative 'mapper/engine' require_relative 'mapper/type/concrete' +require_relative 'mapper/type/registry' module AMA module Entity @@ -11,7 +12,7 @@ class Mapper attr_reader :engine def initialize(engine = nil) - @engine = engine || Engine.new + @engine = engine || Engine.new(Type::Registry.new.with_default_types) end def types @@ -27,12 +28,20 @@ def resolve(type) @engine.resolve(type) end + def map(input, *types, **options) + @engine.map(input, *types, **options) + end + + def [](klass) + @engine.registry[klass] + end + class << self def initialize @mapper = Mapper.new end - def swap(mapper) + def handler=(mapper) @mapper = mapper end diff --git a/lib/mapper/api/default/denormalizer.rb b/lib/mapper/api/default/denormalizer.rb index ce66ed2..115b702 100644 --- a/lib/mapper/api/default/denormalizer.rb +++ b/lib/mapper/api/default/denormalizer.rb @@ -36,7 +36,7 @@ def denormalize(source, type, context = nil) def validate_source!(source, type, context) return if source.is_a?(Hash) - message = "Expected hash, #{source.class} provided " \ + message = "Expected Hash, #{source.class} provided " \ "(while denormalizing #{type})" mapping_error(message, context: context) end diff --git a/lib/mapper/api/default/enumerator.rb b/lib/mapper/api/default/enumerator.rb index c97a6a1..6aa15d7 100644 --- a/lib/mapper/api/default/enumerator.rb +++ b/lib/mapper/api/default/enumerator.rb @@ -18,10 +18,11 @@ class Enumerator < API::Injector # @param [Object] entity # @param [AMA::Entity::Mapper::Type] type - # @param [AMA::Entity::Mapper::Context] context + # @param [AMA::Entity::Mapper::Context] _context def enumerate(entity, type, _context = nil) ::Enumerator.new do |yielder| type.attributes.values.reject(&:virtual).each do |attribute| + next unless object_variable_exists(entity, attribute.name) value = object_variable(entity, attribute.name) segment = Path::Segment.attribute(attribute.name) yielder << [attribute, value, segment] diff --git a/lib/mapper/dsl.rb b/lib/mapper/dsl.rb index 31f73c2..d0d474f 100644 --- a/lib/mapper/dsl.rb +++ b/lib/mapper/dsl.rb @@ -10,10 +10,10 @@ class Mapper module DSL class << self def included(klass) - klass.class_eval do + klass.singleton_class.instance_eval do include ClassMethods - self.mapper = Mapper.handler end + klass.mapper = Mapper.handler end end end diff --git a/lib/mapper/dsl/class_methods.rb b/lib/mapper/dsl/class_methods.rb index 93a2f6e..441f7cc 100644 --- a/lib/mapper/dsl/class_methods.rb +++ b/lib/mapper/dsl/class_methods.rb @@ -19,7 +19,7 @@ def mapper=(mapper) # @return [AMA::Entity::Mapper::Type::Concrete] def bound_type - @mapper.types[self] + mapper[self] end # @param [String, Symbol] name @@ -30,6 +30,12 @@ def bound_type def attribute(name, *types, **options) types = types.map { |type| @mapper.resolve(type) } bound_type.attribute!(name, *types, **options) + define_method(name) do + instance_variable_get("@#{name}") + end + define_method("#{name}=") do |value| + instance_variable_set("@#{name}", value) + end end # @param [String, Symbol] id @@ -38,13 +44,22 @@ def parameter(id) bound_type.parameter!(id) end - %i[factory enumerator injector normalizer denormalizer].each do |m| - define_method m do |handler| - bound_type.send(m, handler) + handlers = { + factory: :create, + enumerator: :enumerate, + injector: :inject, + normalizer: :normalize, + denormalizer: :denormalize + } + handlers.each do |name, method_name| + setter_name = "#{name}=" + define_method setter_name do |handler| + wrapper = API::Wrapper.const_get(name.capitalize).new(handler) + bound_type.send(setter_name, wrapper) end - define_method "#{m}_block" do |&block| - bound_type.send(m, &block) + define_method "#{name}_block" do |&block| + send(setter_name, method_object(method_name, &block)) end end end diff --git a/lib/mapper/engine.rb b/lib/mapper/engine.rb index 11fe3cc..e0e946a 100644 --- a/lib/mapper/engine.rb +++ b/lib/mapper/engine.rb @@ -17,9 +17,14 @@ class Mapper class Engine include Mixin::Errors + # @!attribute [r] registry + # @return [Type::Registry] attr_reader :registry + # @!attribute [r] resolver + # @return [Type::Resolver] attr_reader :resolver + # @param [Type::Registry] registry def initialize(registry = nil) @registry = registry || Type::Registry.new @resolver = Type::Resolver.new(@registry) @@ -43,8 +48,10 @@ def map(source, *types, **context_options) end end - def resolve(type) - @resolver.resolve(type) + # Resolves provided definition, creating type hierarchy. + # @param [Array] definition + def resolve(definition) + @resolver.resolve(definition) end private @@ -105,8 +112,7 @@ def map_attributes(entity, type, context) instance = type.factory.create(type, entity, context) enumerator = type.enumerator.enumerate(entity, type, context) enumerator.each do |attribute, value, segment = nil| - segment = Path::Segment.attribute(attribute.name) unless segment - next_context = context.advance(segment) + next_context = segment ? context.advance(segment) : context unless attribute.satisfied_by?(value) value = recursive_map(value, attribute.types, next_context) end diff --git a/lib/mapper/mixin/reflection.rb b/lib/mapper/mixin/reflection.rb index 6b227e2..52c5925 100644 --- a/lib/mapper/mixin/reflection.rb +++ b/lib/mapper/mixin/reflection.rb @@ -44,6 +44,10 @@ def object_variable(object, name) object.instance_variable_get(name) end + def object_variable_exists(object, name) + object.instance_variables.include?("@#{name}".to_sym) + end + def install_object_method(object, name, handler) compliance_error('Handler not provided') unless handler object.define_singleton_method(name, &handler) diff --git a/lib/mapper/path.rb b/lib/mapper/path.rb index 8b6871b..13a363e 100644 --- a/lib/mapper/path.rb +++ b/lib/mapper/path.rb @@ -78,6 +78,10 @@ def size @segments.size end + def segments + @segments.clone + end + # @return [Array] def to_a @segments.clone diff --git a/lib/mapper/type/aux/pair.rb b/lib/mapper/type/aux/hash_tuple.rb similarity index 52% rename from lib/mapper/type/aux/pair.rb rename to lib/mapper/type/aux/hash_tuple.rb index f6322e4..f7936aa 100644 --- a/lib/mapper/type/aux/pair.rb +++ b/lib/mapper/type/aux/hash_tuple.rb @@ -6,22 +6,22 @@ class Mapper class Type module Aux # Simple class to store paired data items - class Pair - attr_accessor :left - attr_accessor :right + class HashTuple + attr_accessor :key + attr_accessor :value - def initialize(left: nil, right: nil) - @left = left - @right = right + def initialize(key: nil, value: nil) + @key = key + @value = value end def hash - @left.hash ^ @right.hash + @key.hash ^ @value.hash end def eql?(other) - return false unless other.is_a?(Pair) - @left == other.left && @right == other.right + return false unless other.is_a?(HashTuple) + @key == other.key && @value == other.value end def ==(other) diff --git a/lib/mapper/type/hardwired/hash_tuple_type.rb b/lib/mapper/type/hardwired/hash_tuple_type.rb new file mode 100644 index 0000000..d654bc3 --- /dev/null +++ b/lib/mapper/type/hardwired/hash_tuple_type.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative '../concrete' +require_relative '../aux/hash_tuple' + +module AMA + module Entity + class Mapper + class Type + module Hardwired + # Pair class definition + class HashTupleType < Concrete + def initialize + super(Aux::HashTuple) + + attribute!(:key, parameter!(:K)) + attribute!(:value, parameter!(:V)) + + enumerator_block do |entity, type, *| + ::Enumerator.new do |yielder| + yielder << [type.attributes[:key], entity.key, nil] + yielder << [type.attributes[:value], entity.value, nil] + end + end + end + + INSTANCE = new + end + end + end + end + end +end diff --git a/lib/mapper/type/hardwired/hash_type.rb b/lib/mapper/type/hardwired/hash_type.rb index 9b9c717..9fc2e72 100644 --- a/lib/mapper/type/hardwired/hash_type.rb +++ b/lib/mapper/type/hardwired/hash_type.rb @@ -4,8 +4,8 @@ require_relative '../../path/segment' require_relative '../../mixin/errors' require_relative '../../mixin/reflection' -require_relative 'pair_type' -require_relative '../aux/pair' +require_relative 'hash_tuple_type' +require_relative '../aux/hash_tuple' module AMA module Entity @@ -29,10 +29,10 @@ def initialize private def define_attribute - type = PairType.new + type = HashTupleType.new type = type.resolve( - type.parameter!(:L) => parameter!(:K), - type.parameter!(:R) => parameter!(:V) + type.parameter!(:K) => parameter!(:K), + type.parameter!(:V) => parameter!(:V) ) attribute!(:_tuple, type, virtual: true) end @@ -41,7 +41,7 @@ def define_enumerator enumerator_block do |entity, type, *| ::Enumerator.new do |yielder| entity.each do |key, value| - tuple = Aux::Pair.new(left: key, right: value) + tuple = Aux::HashTuple.new(key: key, value: value) attribute = type.attributes[:_tuple] yielder << [attribute, tuple, Path::Segment.index(key)] end @@ -51,7 +51,7 @@ def define_enumerator def define_injector injector_block do |entity, _, _, tuple, *| - entity[tuple.left] = tuple.right + entity[tuple.key] = tuple.value end end diff --git a/lib/mapper/type/hardwired/pair_type.rb b/lib/mapper/type/hardwired/pair_type.rb deleted file mode 100644 index 8a73b02..0000000 --- a/lib/mapper/type/hardwired/pair_type.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require_relative '../concrete' -require_relative '../aux/pair' - -module AMA - module Entity - class Mapper - class Type - module Hardwired - # Pair class definition - class PairType < Concrete - def initialize - super(Aux::Pair) - - attribute!(:left, parameter!(:L)) - attribute!(:right, parameter!(:R)) - end - - INSTANCE = new - end - end - end - end - end -end diff --git a/lib/mapper/type/registry.rb b/lib/mapper/type/registry.rb index 6d7b4ab..04f1854 100644 --- a/lib/mapper/type/registry.rb +++ b/lib/mapper/type/registry.rb @@ -5,7 +5,7 @@ require_relative 'parameter' require_relative 'hardwired/enumerable_type' require_relative 'hardwired/hash_type' -require_relative 'hardwired/pair_type' +require_relative 'hardwired/hash_tuple_type' require_relative 'hardwired/set_type' require_relative 'hardwired/primitive_type' @@ -28,7 +28,7 @@ def with_default_types register(Hardwired::EnumerableType::INSTANCE) register(Hardwired::HashType::INSTANCE) register(Hardwired::SetType::INSTANCE) - register(Hardwired::PairType::INSTANCE) + register(Hardwired::HashTupleType::INSTANCE) Hardwired::PrimitiveType::ALL.each do |type| register(type) end diff --git a/test/suite/acceptance/mapper.case_1.spec.rb b/test/suite/acceptance/mapper.case_1.spec.rb new file mode 100644 index 0000000..c721f14 --- /dev/null +++ b/test/suite/acceptance/mapper.case_1.spec.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +require_relative '../../../lib/mapper' +require_relative '../../../lib/mapper/dsl' +require_relative '../../../lib/mapper/type/any' + +klass = ::AMA::Entity::Mapper +any_type = ::AMA::Entity::Mapper::Type::Any::INSTANCE + +factory = lambda do |name, &block| + Class.new do + include ::AMA::Entity::Mapper::DSL + instance_eval(&block) if block + define_singleton_method :to_s do + name + end + end +end + +describe klass do + before(:each) do + klass.handler = klass.new + end + + let(:public_key) do + factory.call('PublicKey') do + attribute :id, Symbol + attribute :owner, Symbol + attribute :content, String, sensitive: true + attribute :digest, Integer, NilClass, nullable: true + attribute :type, Symbol, values: %i[ssh-rsa ssh-dss], default: :'ssh-rsa' + attribute :comment, Symbol, NilClass + + define_method(:content=) do |content| + @content = content + @digest = content.size + end + + denormalizer_block do |input, type, context, &block| + input = { content: input } if input.is_a?(String) + %i[id owner].each_with_index do |key, index| + candidate = context.path.segments[(-1 - index)] + input[key] = candidate.name if !input[key] && candidate + end + # TODO: when default functionality will be done, remove that line + input[:type] = :'ssh-rsa' unless input[:type] || input['type'] + block.call(input, type, context) + end + end + end + + let(:private_key_host) do + factory.call('PrivateKey.Host') do + attribute :id, Symbol + attribute :options, [Hash, K: Symbol, V: [String, Symbol, Integer]] + + denormalizer_block do |input, type, context, &block| + input = {} if input.nil? + input = { User: input } if input.is_a?(String) || input.is_a?(Symbol) + data = {} + data[:id] = input[:id] || input['id'] || context.path.current.name + data[:options] = input[:options] || input['options'] || {} + input.each do |key, value| + next if %i[id options].include?(key.to_sym) + data[:options][key] = value + end + block.call(data, type, context) + end + end + end + + let(:private_key) do + private_key_host = self.private_key_host + factory.call('PrivateKey') do + attribute :id, Symbol + attribute :owner, Symbol + attribute :content, String, sensitive: true + attribute :digest, Integer, NilClass, nullable: true + attribute :hosts, [Hash, K: Symbol, V: private_key_host] + + denormalizer_block do |input, type, context, &block| + input = { content: input } if input.is_a?(String) + %i[id owner].each_with_index do |key, index| + candidate = context.path.segments[(-1 - index)] + input[key] = candidate.name if !input[key] && candidate + end + input[:hosts] = {} unless input[:hosts] || input['hosts'] + block.call(input, type, context) + end + + define_method(:content=) do |content| + @content = content + @digest = content.size + end + end + end + + let(:privilege) do + factory.call('Privilege') do + attribute :id, Symbol + attribute :options, [Hash, K: Symbol, V: any_type] + denormalizer_block do |data, type, context, &block| + data = {} if data.nil? + target = { options: data[:options] || {} } + target[:id] = data[:id] || context.path.current.name + data.each do |key, value| + next if %i[id options].include?(key) + target[:options][key] = value + end + block.call(target, type, context) + end + end + end + + let(:account) do + privilege = self.privilege + public_key = self.public_key + private_key = self.private_key + factory.call('Account') do + attribute :id, Symbol + attribute :policy, Symbol, values: %i[none edit manage] + attribute :privileges, [Hash, K: Symbol, V: privilege] + attribute :public_keys, [Hash, K: Symbol, V: [Hash, K: Symbol, V: public_key]] + attribute :private_keys, [Hash, K: Symbol, V: private_key] + denormalizer_block do |data, type, context, &block| + data[:id] = context.path.current.name unless data[:id] + block.call(data, type, context) + end + end + end + + describe '> account mapping' do + it 'solves case #1' do |test_case| + input = { + 'bill' => { + 'policy' => 'manage', + 'privileges' => { + 'sudo' => nil, + 'mount' => { + 'disks' => ['/dev/sda'] + } + }, + 'private_keys' => { + 'id_rsa' => 'private key', + 'id_bsa' => { + 'content' => 'private key', + 'hosts' => { + 'private.server' => nil, + 'github.com' => 'git', + 'secure.server' => { + Port: 22, + User: 'engineer', + Host: 'secure.company.com' + } + } + } + }, + 'public_keys' => { + 'bill' => { + 'id_rsa' => 'public key', + 'id_bsa' => { + 'content' => 'public key', + 'type' => 'ssh-dss' + } + } + } + } + } + + mapped = nil + test_case.step 'mapping' do + mapped = klass.map(input, [Hash, K: Symbol, V: account]) + end + + test_case.step 'external hash validation' do + expect(mapped).to be_a(Hash) + expect(mapped).to include(:bill) + end + + bill = nil + test_case.step 'account validation' do + bill = mapped[:bill] + expect(bill).to be_a(account) + expect(bill.policy).to eq(:manage) + expect(bill.privileges).to include(:sudo, :mount) + expect(bill) + end + + test_case.step 'sudo privilege validation' do + expect(bill.privileges).to include(:sudo) + sudo = bill.privileges[:sudo] + expect(sudo).to be_a(privilege) + expect(sudo.id).to eq(:sudo) + expect(sudo.options).to eq({}) + end + test_case.step 'mount privilege valudation' do + expect(bill.privileges).to include(:mount) + mount = bill.privileges[:mount] + expect(mount).to be_a(privilege) + expect(mount.id).to eq(:mount) + expect(mount.options).to eq(disks: ['/dev/sda']) + end + + keyring = {} + test_case.step 'public keyring validation' do + expect(bill.public_keys).to include(:bill) + keyring = bill.public_keys[:bill] + end + + test_case.step 'id_rsa public key validation' do + expect(keyring).to include(:id_rsa) + key = keyring[:id_rsa] + expect(key).to be_a(public_key) + expect(key.id).to eq(:id_rsa) + expect(key.type).to eq(:'ssh-rsa') + expect(key.content).to eq('public key') + expect(key.digest).to eq(key.content.size) + end + + test_case.step 'id_bsa public key validation' do + expect(keyring).to include(:id_bsa) + key = keyring[:id_bsa] + expect(key).to be_a(public_key) + expect(key.id).to eq(:id_bsa) + expect(key.type).to eq(:'ssh-dss') + expect(key.content).to eq('public key') + expect(key.digest).to eq(key.content.size) + end + + test_case.step 'id_rsa private key validation' do + expect(bill.private_keys).to include(:id_rsa) + key = bill.private_keys[:id_rsa] + expect(key).to be_a(private_key) + expect(key.id).to eq(:id_rsa) + expect(key.content).to eq('private key') + expect(key.digest).to eq(key.content.size) + # TODO: default value check for hosts + end + + test_case.step 'id_bsa private key validation' do + expect(bill.private_keys).to include(:id_bsa) + key = bill.private_keys[:id_bsa] + expect(key).to be_a(private_key) + expect(key.id).to eq(:id_bsa) + expect(key.content).to eq('private key') + expect(key.digest).to eq(key.content.size) + hosts = %i[private.server github.com secure.server] + expect(key.hosts).to include(*hosts) + hosts.each do |hostname| + host = key.hosts[hostname] + expect(host).to be_a(private_key_host) + expect(host.id).to eq(hostname) + expect(host.options).to be_a(Hash) + end + expect(key.hosts[:'github.com'].options[:User]).to eq('git') + expectation = { + Port: 22, + User: 'engineer', + Host: 'secure.company.com' + } + expect(key.hosts[:'secure.server'].options).to eq(expectation) + end + end + end +end diff --git a/test/suite/integration/mapper/type/concrete.spec.rb b/test/suite/integration/mapper/type/concrete.spec.rb index e7da3d9..ea344cb 100644 --- a/test/suite/integration/mapper/type/concrete.spec.rb +++ b/test/suite/integration/mapper/type/concrete.spec.rb @@ -135,10 +135,13 @@ def self.to_s it 'should provide default enumerator' do type = klass.new(dummy_class) type.attributes[:id] = double(name: :id, virtual: false) + specimen = dummy_class.new + specimen.id = :symbol proc = lambda do |handler| - type.enumerator.enumerate(dummy_class.new, type).each(&handler) + type.enumerator.enumerate(specimen, type).each(&handler) end - expect(&proc).to yield_with_args([type.attributes[:id], nil, anything]) + args = [type.attributes[:id], specimen.id, anything] + expect(&proc).to yield_with_args(args) end end @@ -196,6 +199,10 @@ def self.to_s it 'passes call to all attributes using enumerator call' do type = klass.new(dummy_class) input = double(is_a?: true) + input.singleton_class.instance_eval do + attr_accessor :value + end + input.value = :value attribute = type.attribute!(:value, dummy_class) expect(type).to receive(:instance?).and_call_original expect(attribute).to receive(:satisfied_by?).and_return(false) diff --git a/test/suite/integration/mapper/type/hardwired/hash_type.spec.rb b/test/suite/integration/mapper/type/hardwired/hash_type.spec.rb index 62de693..9ec610f 100644 --- a/test/suite/integration/mapper/type/hardwired/hash_type.spec.rb +++ b/test/suite/integration/mapper/type/hardwired/hash_type.spec.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true require_relative '../../../../../../lib/mapper/type/hardwired/hash_type' -require_relative '../../../../../../lib/mapper/type/aux/pair' +require_relative '../../../../../../lib/mapper/type/aux/hash_tuple' require_relative '../../../../../../lib/mapper/exception/mapping_error' require_relative '../../../../../../lib/mapper/path/segment' klass = ::AMA::Entity::Mapper::Type::Hardwired::HashType -pair_class = ::AMA::Entity::Mapper::Type::Aux::Pair +tuple_class = ::AMA::Entity::Mapper::Type::Aux::HashTuple segment_class = ::AMA::Entity::Mapper::Path::Segment mapping_error_class = ::AMA::Entity::Mapper::Exception::MappingError @@ -21,19 +21,20 @@ proc = lambda do |block| type.enumerator.enumerate(source, type).each(&block) end - pair = pair_class.new(left: :id, right: 12) + tuple = tuple_class.new(key: :id, value: 12) attribute = type.attributes[:_tuple] - expect(&proc).to yield_with_args([attribute, pair, anything]) + expect(&proc).to yield_with_args([attribute, tuple, anything]) end end describe '#injector' do it 'should provide correctly-behaving acceptor' do object = {} - tuple = pair_class.new(left: :key, right: :value) + tuple = tuple_class.new(key: :key, value: :value) expectation = { key: :value } segment = segment_class.index(:key) - type.injector.inject(object, type, type.attributes[:_tuple], tuple, segment) + attribute = type.attributes[:_tuple] + type.injector.inject(object, type, attribute, tuple, segment) expect(object).to eq(expectation) end end diff --git a/test/suite/unit/mapper/type/aux/pair.spec.rb b/test/suite/unit/mapper/type/aux/pair.spec.rb index 0836aff..9648eb3 100644 --- a/test/suite/unit/mapper/type/aux/pair.spec.rb +++ b/test/suite/unit/mapper/type/aux/pair.spec.rb @@ -1,25 +1,25 @@ # frozen_string_literal: true -require_relative '../../../../../../lib/mapper/type/aux/pair' +require_relative '../../../../../../lib/mapper/type/aux/hash_tuple' -klass = ::AMA::Entity::Mapper::Type::Aux::Pair +klass = ::AMA::Entity::Mapper::Type::Aux::HashTuple describe klass do describe '#eql?' do it 'should return true for pairs with same content' do - data = { left: 1, right: 2 } + data = { key: 1, value: 2 } expect(klass.new(data)).to eq(klass.new(data)) end it 'should return false for pairs with different content' do - data = { left: 1, right: 2 } - expect(klass.new(data)).not_to eq(klass.new(data.merge(right: 3))) + data = { key: 1, value: 2 } + expect(klass.new(data)).not_to eq(klass.new(data.merge(value: 3))) end end describe '#hash' do it 'should return same values for pairs with same content' do - data = { left: 1, right: 2 } + data = { key: 1, value: 2 } expect(klass.new(data).hash).to eq(klass.new(data).hash) end end