├── .gitignore ├── .rspec ├── .ruby-gemset ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── Guardfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── rom-mapper.rb └── rom │ ├── header.rb │ ├── header │ └── attribute.rb │ ├── mapper.rb │ ├── mapper │ ├── attribute_dsl.rb │ ├── dsl.rb │ ├── model_dsl.rb │ └── version.rb │ ├── model_builder.rb │ ├── processor.rb │ └── processor │ └── transproc.rb ├── rakelib ├── benchmark.rake ├── mutant.rake └── rubocop.rake ├── rom-mapper.gemspec └── spec ├── integration └── mapper_spec.rb ├── spec_helper.rb ├── support ├── constant_leak_finder.rb └── mutant.rb └── unit └── rom ├── mapper ├── dsl_spec.rb └── model_dsl_spec.rb ├── mapper_spec.rb └── processor └── transproc_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | log 2 | *.rbx 3 | *.rbc 4 | Gemfile.lock 5 | vendor/ 6 | tmp/ 7 | doc/ 8 | coverage/ 9 | measurements/ 10 | graphviz/ 11 | .yardoc/ 12 | .bundle/ 13 | *.png 14 | Vagrantfile 15 | .vagrant 16 | 17 | # spiking area 18 | scratchpad.rb 19 | .ruby-version 20 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --order random 3 | --warnings 4 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | rom 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | dist: trusty 3 | sudo: false 4 | cache: bundler 5 | bundler_args: --without sql benchmarks console tools 6 | script: "bundle exec rake ci" 7 | after_success: 8 | - '[ -d coverage ] && bundle exec codeclimate-test-reporter' 9 | rvm: 10 | - 2.2.7 11 | - 2.3.4 12 | - 2.4.1 13 | - jruby-9.1.8.0 14 | env: 15 | global: 16 | - JRUBY_OPTS='--dev -J-Xmx1024M' 17 | notifications: 18 | webhooks: 19 | urls: 20 | - https://webhooks.gitter.im/e/39e1225f489f38b0bd09 21 | on_success: change 22 | on_failure: always 23 | on_start: false 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.5.1 2017-05-04 2 | 3 | ### Changed 4 | 5 | * The `dry-core` dependency has been relaxed (flash-gordon) 6 | 7 | [Compare v0.5.0..v0.5.1](https://github.com/rom-rb/rom-mapper/compare/v0.5.0...v0.5.1) 8 | 9 | # 0.5.0 2017-01-29 10 | 11 | ### Changed 12 | 13 | * Drop rom-support dependency in favor of dry-core (flash-gordon) 14 | 15 | [Compare v0.4.0..v0.5.0](https://github.com/rom-rb/rom-mapper/compare/v0.4.0...v0.5.0) 16 | 17 | # v0.4.0 2016-07-27 18 | 19 | ### Changed 20 | 21 | * Raise a meaningful exception if no coercer exists (astupka) 22 | * Don’t reject keys if copy_keys option is true (astupka) 23 | 24 | [Compare v0.3.0..v0.4.0](https://github.com/rom-rb/rom-mapper/compare/v0.3.0...v0.4.0) 25 | 26 | # v0.3.0 2016-01-06 27 | 28 | ### Added 29 | 30 | * Allow `attribute`'s `:from` option to take an array of other attribute names (hmadison) 31 | 32 | ### Changed 33 | 34 | * Coercer blocks are now executed in the context of the mapper object (AMHOL) 35 | 36 | ### Fixed 37 | 38 | * `model` will skip excluded attributes (chastell) 39 | 40 | [Compare v0.2.0..v0.3.0](https://github.com/rom-rb/rom-mapper/compare/v0.2.0...v0.3.0) 41 | 42 | # v0.2.0 2015-08-10 43 | 44 | Import code from rom 0.8.1 45 | 46 | [Compare v0.1.1..v0.2.0](https://github.com/rom-rb/rom-mapper/compare/v0.1.0...v0.2.0) 47 | 48 | # v0.1.1 2013-09-02 49 | 50 | * [internal] Moved version file to rom/mapper/version (solnic) 51 | 52 | [Compare v0.1.0..v0.1.1](https://github.com/rom-rb/rom-mapper/compare/v0.1.0...v0.1.1) 53 | 54 | # v0.1.0 2013-08-23 55 | 56 | First public release 57 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :test do 6 | gem 'anima', '~> 0.3.0' 7 | gem 'virtus' 8 | gem 'inflecto', '~> 0.0', '>= 0.0.2' 9 | 10 | gem 'codeclimate-test-reporter', require: false 11 | gem 'simplecov', require: false 12 | end 13 | 14 | group :benchmarks do 15 | gem 'benchmark-ips', '~> 2.2' 16 | end 17 | 18 | group :tools do 19 | gem 'rubocop', '~> 0.31' 20 | 21 | gem 'guard' 22 | gem 'guard-rspec' 23 | gem 'guard-rubocop' 24 | 25 | gem 'byebug' 26 | 27 | platform :mri do 28 | gem 'mutant', '~> 0.8.0' 29 | gem 'mutant-rspec' 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :rspec do 2 | #run all specs if configuration is modified 3 | watch('Guardfile') { 'spec' } 4 | watch('Gemfile.lock') { 'spec' } 5 | watch('spec/spec_helper.rb') { 'spec' } 6 | 7 | # run all specs if supporting files files are modified 8 | watch(%r{\Aspec/(?:lib|support|shared)/.+\.rb\z}) { 'spec' } 9 | 10 | # run unit specs if associated lib code is modified 11 | watch(%r{\Alib/(.+)\.rb\z}) { |m| Dir["spec/unit/#{m[1]}"] } 12 | watch(%r{\Alib/(.+)/support/(.+)\.rb\z}) { |m| Dir["spec/unit/#{m[1]}/#{m[2]}"] } 13 | watch("lib/#{File.basename(File.expand_path('../', __FILE__))}.rb") { 'spec' } 14 | 15 | # run a spec if it is modified 16 | watch(%r{\Aspec/(?:unit|integration)/.+_spec\.rb\z}) 17 | 18 | notification :tmux, :display_message => true 19 | end 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Ruby Object Mapper Team 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This project was moved to [rom-rb/rom](https://github.com/rom-rb/rom) 2 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | require "rake/testtask" 4 | 5 | RSpec::Core::RakeTask.new(:spec) 6 | task default: [:ci] 7 | 8 | desc 'Run specs in isolation' 9 | task :"spec:isolation" do 10 | FileList["spec/**/*_spec.rb"].each do |spec| 11 | sh "rspec", spec 12 | end 13 | end 14 | 15 | desc "Run CI tasks" 16 | task ci: [:spec] 17 | -------------------------------------------------------------------------------- /lib/rom-mapper.rb: -------------------------------------------------------------------------------- 1 | require 'dry-equalizer' 2 | 3 | require 'rom/mapper' 4 | require 'rom/processor/transproc' 5 | 6 | module ROM 7 | MapperMisconfiguredError = Class.new(StandardError) 8 | end 9 | -------------------------------------------------------------------------------- /lib/rom/header.rb: -------------------------------------------------------------------------------- 1 | require 'rom/header/attribute' 2 | 3 | module ROM 4 | # Header provides information about data mapping of a specific relation 5 | # 6 | # Processors use headers to build objects that process raw relations that go 7 | # through mappers. 8 | # 9 | # @private 10 | class Header 11 | include Enumerable 12 | include Dry::Equalizer(:attributes, :model) 13 | 14 | # @return [Class] optional model associated with a header 15 | # 16 | # @api private 17 | attr_reader :model 18 | 19 | # @api private 20 | attr_reader :reject_keys 21 | 22 | # @api private 23 | attr_reader :copy_keys 24 | 25 | # @api private 26 | attr_reader :attributes 27 | 28 | # @return [Hash] attribute key/name mapping for all primitive attributes 29 | # 30 | # @api private 31 | attr_reader :mapping 32 | 33 | # @return [Array] all attribute keys that are in a tuple 34 | # 35 | # @api private 36 | attr_reader :tuple_keys 37 | 38 | # @return [Array] all attribute names that are popping from a tuple 39 | # 40 | # @api private 41 | attr_reader :pop_keys 42 | 43 | # Coerce array with attribute definitions into a header object 44 | # 45 | # @param [Array] input attribute name/option pairs 46 | # 47 | # @param [Class] model optional 48 | # 49 | # @return [Header] 50 | # 51 | # @api private 52 | def self.coerce(input, options = {}) 53 | if input.instance_of?(self) 54 | input 55 | else 56 | attributes = input.each_with_object({}) { |pair, h| 57 | h[pair.first] = Attribute.coerce(pair) 58 | } 59 | 60 | new(attributes, options) 61 | end 62 | end 63 | 64 | # @api private 65 | def initialize(attributes, options = {}) 66 | @options = options 67 | @model = options[:model] 68 | @copy_keys = options.fetch(:copy_keys, false) 69 | @reject_keys = options.fetch(:reject_keys, false) 70 | 71 | @attributes = attributes 72 | initialize_mapping 73 | initialize_tuple_keys 74 | initialize_pop_keys 75 | end 76 | 77 | # Iterate over attributes 78 | # 79 | # @yield [Attribute] 80 | # 81 | # @api private 82 | def each 83 | attributes.each_value { |attribute| yield(attribute) } 84 | end 85 | 86 | # Return if there are any aliased attributes 87 | # 88 | # @api private 89 | def aliased? 90 | any?(&:aliased?) 91 | end 92 | 93 | # Return attribute keys 94 | # 95 | # An attribute key corresponds to tuple attribute names 96 | # 97 | # @api private 98 | def keys 99 | attributes.keys 100 | end 101 | 102 | # Return attribute identified by its name 103 | # 104 | # @return [Attribute] 105 | # 106 | # @api private 107 | def [](name) 108 | attributes.fetch(name) 109 | end 110 | 111 | # Return all Combined attributes 112 | # 113 | # @return [Array] 114 | # 115 | # @api private 116 | def combined 117 | by_type(Combined) 118 | end 119 | 120 | # Returns all attributes that require preprocessing 121 | # 122 | # @return [Array] 123 | # 124 | # @api private 125 | def preprocessed 126 | by_type(Group, Fold) 127 | end 128 | 129 | # Returns all attributes that require postprocessing 130 | # 131 | # @return [Array] 132 | # 133 | # @api private 134 | def postprocessed 135 | by_type(Ungroup, Unfold) 136 | end 137 | 138 | # Return all Wrap attributes 139 | # 140 | # @return [Array] 141 | # 142 | # @api private 143 | def wraps 144 | by_type(Wrap) 145 | end 146 | 147 | # Return all non-primitive attributes that don't require mapping 148 | # 149 | # @return [Array] 150 | # 151 | # @api private 152 | def non_primitives 153 | preprocessed + wraps 154 | end 155 | 156 | # Return all primitive attributes that require mapping 157 | # 158 | # @return [Array] 159 | # 160 | # @api private 161 | def primitives 162 | to_a - non_primitives 163 | end 164 | 165 | private 166 | 167 | # Find all attribute matching specific attribute class (not kind) 168 | # 169 | # @return [Array] 170 | # 171 | # @api private 172 | def by_type(*types) 173 | select { |attribute| types.include?(attribute.class) } 174 | end 175 | 176 | # Set mapping hash from primitive attributes 177 | # 178 | # @api private 179 | def initialize_mapping 180 | @mapping = primitives.map(&:mapping).reduce(:merge) || {} 181 | end 182 | 183 | # Set all tuple keys from all attributes going deep into Wrap and Group too 184 | # 185 | # @api private 186 | def initialize_tuple_keys 187 | @tuple_keys = mapping.keys.flatten + non_primitives.flat_map(&:tuple_keys) 188 | end 189 | 190 | # Set all tuple keys from all attributes popping from Unwrap and Ungroup 191 | # 192 | # @api private 193 | def initialize_pop_keys 194 | @pop_keys = mapping.values + non_primitives.flat_map(&:tuple_keys) 195 | end 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /lib/rom/header/attribute.rb: -------------------------------------------------------------------------------- 1 | module ROM 2 | class Header 3 | # An attribute provides information about a specific attribute in a tuple 4 | # 5 | # This may include information about how an attribute should be renamed, 6 | # or how its value should coerced. 7 | # 8 | # More complex attributes describe how an attribute should be transformed. 9 | # 10 | # @private 11 | class Attribute 12 | include Dry::Equalizer(:name, :key, :type) 13 | 14 | # @return [Symbol] name of an attribute 15 | # 16 | # @api private 17 | attr_reader :name 18 | 19 | # @return [Symbol] key of an attribute that corresponds to tuple attribute 20 | # 21 | # @api private 22 | attr_reader :key 23 | 24 | # @return [Symbol] type identifier (defaults to :object) 25 | # 26 | # @api private 27 | attr_reader :type 28 | 29 | # @return [Hash] additional meta information 30 | # 31 | # @api private 32 | attr_reader :meta 33 | 34 | # Return attribute class for a given meta hash 35 | # 36 | # @param [Hash] meta hash with type information and optional transformation info 37 | # 38 | # @return [Class] 39 | # 40 | # @api private 41 | def self.[](meta) 42 | key = (meta.keys & TYPE_MAP.keys).first 43 | TYPE_MAP.fetch(key || meta[:type], self) 44 | end 45 | 46 | # Coerce an array with attribute meta-data into an attribute object 47 | # 48 | # @param [Array] input attribute name/options pair 49 | # 50 | # @return [Attribute] 51 | # 52 | # @api private 53 | def self.coerce(input) 54 | name = input[0] 55 | meta = (input[1] || {}).dup 56 | 57 | meta[:type] ||= :object 58 | 59 | if meta.key?(:header) 60 | meta[:header] = Header.coerce(meta[:header], model: meta[:model]) 61 | end 62 | 63 | self[meta].new(name, meta) 64 | end 65 | 66 | # @api private 67 | def initialize(name, meta) 68 | @name = name 69 | @meta = meta 70 | @key = meta.fetch(:from) { name } 71 | @type = meta.fetch(:type) 72 | end 73 | 74 | # Return if an attribute has a specific type identifier 75 | # 76 | # @api private 77 | def typed? 78 | type != :object 79 | end 80 | 81 | # Return if an attribute should be aliased 82 | # 83 | # @api private 84 | def aliased? 85 | key != name 86 | end 87 | 88 | # Return :key-to-:name mapping hash 89 | # 90 | # @return [Hash] 91 | # 92 | # @api private 93 | def mapping 94 | { key => name } 95 | end 96 | 97 | def union? 98 | key.is_a? ::Array 99 | end 100 | end 101 | 102 | # Embedded attribute is a special attribute type that has a header 103 | # 104 | # This is the base of complex attributes like Hash or Group 105 | # 106 | # @private 107 | class Embedded < Attribute 108 | include Dry::Equalizer(:name, :key, :type, :header) 109 | 110 | # return [Header] header of an attribute 111 | # 112 | # @api private 113 | attr_reader :header 114 | 115 | # @api private 116 | def initialize(*) 117 | super 118 | @header = meta.fetch(:header) 119 | end 120 | 121 | # Return tuple keys from the header 122 | # 123 | # @return [Array] 124 | # 125 | # @api private 126 | def tuple_keys 127 | header.tuple_keys 128 | end 129 | 130 | def pop_keys 131 | header.pop_keys 132 | end 133 | end 134 | 135 | # Array is an embedded attribute type 136 | Array = Class.new(Embedded) 137 | 138 | # Hash is an embedded attribute type 139 | Hash = Class.new(Embedded) 140 | 141 | # Combined is an embedded attribute type describing combination of multiple 142 | # relations 143 | Combined = Class.new(Embedded) 144 | 145 | # Wrap is a special type of Hash attribute that requires wrapping 146 | # transformation 147 | Wrap = Class.new(Hash) 148 | 149 | # Unwrap is a special type of Hash attribute that requires unwrapping 150 | # transformation 151 | Unwrap = Class.new(Hash) 152 | 153 | # Group is a special type of Array attribute that requires grouping 154 | # transformation 155 | Group = Class.new(Array) 156 | 157 | # Ungroup is a special type of Array attribute that requires ungrouping 158 | # transformation 159 | Ungroup = Class.new(Array) 160 | 161 | # Fold is a special type of Array attribute that requires folding 162 | # transformation 163 | Fold = Class.new(Array) 164 | 165 | # Unfold is a special type of Array attribute that requires unfolding 166 | # transformation 167 | Unfold = Class.new(Array) 168 | 169 | # Exclude is a special type of Attribute to be removed 170 | Exclude = Class.new(Attribute) 171 | 172 | # TYPE_MAP is a (hash) map of ROM::Header identifiers to ROM::Header types 173 | # 174 | # @private 175 | TYPE_MAP = { 176 | combine: Combined, 177 | wrap: Wrap, 178 | unwrap: Unwrap, 179 | group: Group, 180 | ungroup: Ungroup, 181 | fold: Fold, 182 | unfold: Unfold, 183 | hash: Hash, 184 | array: Array, 185 | exclude: Exclude 186 | } 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /lib/rom/mapper.rb: -------------------------------------------------------------------------------- 1 | require 'dry/core/constants' 2 | require 'rom/mapper/dsl' 3 | 4 | module ROM 5 | # Mapper is a simple object that uses transformers to load relations 6 | # 7 | # @private 8 | class Mapper 9 | include Dry::Core::Constants 10 | include DSL 11 | include Dry::Equalizer(:transformers, :header) 12 | 13 | defines :relation, :register_as, :symbolize_keys, :copy_keys, 14 | :prefix, :prefix_separator, :inherit_header, :reject_keys 15 | 16 | inherit_header true 17 | reject_keys false 18 | prefix_separator '_'.freeze 19 | 20 | # @return [Object] transformers object built by a processor 21 | # 22 | # @api private 23 | attr_reader :transformers 24 | 25 | # @return [Header] header that was used to build the transformers 26 | # 27 | # @api private 28 | attr_reader :header 29 | 30 | # @return [Hash] registered processors 31 | # 32 | # @api private 33 | def self.processors 34 | @_processors ||= {} 35 | end 36 | 37 | # Register a processor class 38 | # 39 | # @return [Hash] 40 | # 41 | # @api private 42 | def self.register_processor(processor) 43 | name = processor.name.split('::').last.downcase.to_sym 44 | processors.update(name => processor) 45 | end 46 | 47 | # Prepares an array of headers for a potentially multistep mapper 48 | # 49 | # @return [Array
] 50 | # 51 | # @api private 52 | def self.headers(header) 53 | return [header] if steps.empty? 54 | return steps.map(&:header) if attributes.empty? 55 | raise(MapperMisconfiguredError, "cannot mix outer attributes and steps") 56 | end 57 | 58 | # Build a mapper using provided processor type 59 | # 60 | # @return [Mapper] 61 | # 62 | # @api private 63 | def self.build(header = self.header, processor = :transproc) 64 | new(header, processor) 65 | end 66 | 67 | # @api private 68 | def self.registry(descendants) 69 | descendants.each_with_object({}) do |klass, h| 70 | name = klass.register_as || klass.relation 71 | (h[klass.base_relation] ||= {})[name] = klass.build 72 | end 73 | end 74 | 75 | # @api private 76 | def initialize(header, processor = :transproc) 77 | processor = Mapper.processors.fetch(processor) 78 | @transformers = self.class.headers(header).map do |hdr| 79 | processor.build(self, hdr) 80 | end 81 | @header = header 82 | end 83 | 84 | # @return [Class] optional model that is instantiated by a mapper 85 | # 86 | # @api private 87 | def model 88 | header.model 89 | end 90 | 91 | # Process a relation using the transformers 92 | # 93 | # @api private 94 | def call(relation) 95 | transformers.reduce(relation.to_a) { |a, e| e.call(a) } 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/rom/mapper/attribute_dsl.rb: -------------------------------------------------------------------------------- 1 | require 'rom/header' 2 | require 'rom/mapper/model_dsl' 3 | 4 | module ROM 5 | class Mapper 6 | # Mapper attribute DSL exposed by mapper subclasses 7 | # 8 | # This class is private even though its methods are exposed by mappers. 9 | # Typically it's not meant to be used directly. 10 | # 11 | # TODO: break this madness down into smaller pieces 12 | # 13 | # @api private 14 | class AttributeDSL 15 | include ModelDSL 16 | 17 | attr_reader :attributes, :options, :copy_keys, :symbolize_keys, :reject_keys, :steps 18 | 19 | # @param [Array] attributes accumulator array 20 | # @param [Hash] options 21 | # 22 | # @api private 23 | def initialize(attributes, options) 24 | @attributes = attributes 25 | @options = options 26 | @copy_keys = options.fetch(:copy_keys) 27 | @symbolize_keys = options.fetch(:symbolize_keys) 28 | @prefix = options.fetch(:prefix) 29 | @prefix_separator = options.fetch(:prefix_separator) 30 | @reject_keys = options.fetch(:reject_keys) 31 | @steps = [] 32 | end 33 | 34 | # Redefine the prefix for the following attributes 35 | # 36 | # @example 37 | # 38 | # dsl = AttributeDSL.new([]) 39 | # dsl.attribute(:prefix, 'user') 40 | # 41 | # @api public 42 | def prefix(value = Undefined) 43 | if value.equal?(Undefined) 44 | @prefix 45 | else 46 | @prefix = value 47 | end 48 | end 49 | 50 | # Redefine the prefix separator for the following attributes 51 | # 52 | # @example 53 | # 54 | # dsl = AttributeDSL.new([]) 55 | # dsl.attribute(:prefix_separator, '.') 56 | # 57 | # @api public 58 | def prefix_separator(value = Undefined) 59 | if value.equal?(Undefined) 60 | @prefix_separator 61 | else 62 | @prefix_separator = value 63 | end 64 | end 65 | 66 | # Define a mapping attribute with its options and/or block 67 | # 68 | # @example 69 | # dsl = AttributeDSL.new([]) 70 | # 71 | # dsl.attribute(:name) 72 | # dsl.attribute(:email, from: 'user_email') 73 | # dsl.attribute(:name) { 'John' } 74 | # dsl.attribute(:name) { |t| t.upcase } 75 | # 76 | # @api public 77 | def attribute(name, options = EMPTY_HASH, &block) 78 | with_attr_options(name, options) do |attr_options| 79 | raise ArgumentError, 80 | "can't specify type and block at the same time" if options[:type] && block 81 | attr_options[:coercer] = block if block 82 | add_attribute(name, attr_options) 83 | end 84 | end 85 | 86 | def exclude(name) 87 | attributes << [name, { exclude: true }] 88 | end 89 | 90 | # Perform transformations sequentially 91 | # 92 | # @example 93 | # dsl = AttributeDSL.new() 94 | # 95 | # dsl.step do 96 | # attribute :name 97 | # end 98 | # 99 | # @api public 100 | def step(options = EMPTY_HASH, &block) 101 | steps << new(options, &block) 102 | end 103 | 104 | # Define an embedded attribute 105 | # 106 | # Block exposes the attribute dsl too 107 | # 108 | # @example 109 | # dsl = AttributeDSL.new([]) 110 | # 111 | # dsl.embedded :tags, type: :array do 112 | # attribute :name 113 | # end 114 | # 115 | # dsl.embedded :address, type: :hash do 116 | # model Address 117 | # attribute :name 118 | # end 119 | # 120 | # @param [Symbol] name attribute 121 | # 122 | # @param [Hash] options 123 | # @option options [Symbol] :type Embedded type can be :hash or :array 124 | # @option options [Symbol] :prefix Prefix that should be used for 125 | # its attributes 126 | # 127 | # @api public 128 | def embedded(name, options, &block) 129 | with_attr_options(name) do |attr_options| 130 | mapper = options[:mapper] 131 | 132 | if mapper 133 | embedded_options = { type: :array }.update(options) 134 | attributes_from_mapper( 135 | mapper, name, embedded_options.update(attr_options) 136 | ) 137 | else 138 | dsl = new(options, &block) 139 | attr_options.update(options) 140 | add_attribute( 141 | name, { header: dsl.header, type: :array }.update(attr_options) 142 | ) 143 | end 144 | end 145 | end 146 | 147 | # Define an embedded hash attribute that requires "wrapping" transformation 148 | # 149 | # Typically this is used in sql context when relation is a join. 150 | # 151 | # @example 152 | # dsl = AttributeDSL.new([]) 153 | # 154 | # dsl.wrap(address: [:street, :zipcode, :city]) 155 | # 156 | # dsl.wrap(:address) do 157 | # model Address 158 | # attribute :street 159 | # attribute :zipcode 160 | # attribute :city 161 | # end 162 | # 163 | # @see AttributeDSL#embedded 164 | # 165 | # @api public 166 | def wrap(*args, &block) 167 | ensure_mapper_configuration('wrap', args, block_given?) 168 | 169 | with_name_or_options(*args) do |name, options, mapper| 170 | wrap_options = { type: :hash, wrap: true }.update(options) 171 | 172 | if mapper 173 | attributes_from_mapper(mapper, name, wrap_options) 174 | else 175 | dsl(name, wrap_options, &block) 176 | end 177 | end 178 | end 179 | 180 | # Define an embedded hash attribute that requires "unwrapping" transformation 181 | # 182 | # Typically this is used in no-sql context to normalize data before 183 | # inserting to sql gateway. 184 | # 185 | # @example 186 | # dsl = AttributeDSL.new([]) 187 | # 188 | # dsl.unwrap(address: [:street, :zipcode, :city]) 189 | # 190 | # dsl.unwrap(:address) do 191 | # attribute :street 192 | # attribute :zipcode 193 | # attribute :city 194 | # end 195 | # 196 | # @see AttributeDSL#embedded 197 | # 198 | # @api public 199 | def unwrap(*args, &block) 200 | with_name_or_options(*args) do |name, options, mapper| 201 | unwrap_options = { type: :hash, unwrap: true }.update(options) 202 | 203 | if mapper 204 | attributes_from_mapper(mapper, name, unwrap_options) 205 | else 206 | dsl(name, unwrap_options, &block) 207 | end 208 | end 209 | end 210 | 211 | # Define an embedded hash attribute that requires "grouping" transformation 212 | # 213 | # Typically this is used in sql context when relation is a join. 214 | # 215 | # @example 216 | # dsl = AttributeDSL.new([]) 217 | # 218 | # dsl.group(tags: [:name]) 219 | # 220 | # dsl.group(:tags) do 221 | # model Tag 222 | # attribute :name 223 | # end 224 | # 225 | # @see AttributeDSL#embedded 226 | # 227 | # @api public 228 | def group(*args, &block) 229 | ensure_mapper_configuration('group', args, block_given?) 230 | 231 | with_name_or_options(*args) do |name, options, mapper| 232 | group_options = { type: :array, group: true }.update(options) 233 | 234 | if mapper 235 | attributes_from_mapper(mapper, name, group_options) 236 | else 237 | dsl(name, group_options, &block) 238 | end 239 | end 240 | end 241 | 242 | # Define an embedded array attribute that requires "ungrouping" transformation 243 | # 244 | # Typically this is used in non-sql context being prepared for import to sql. 245 | # 246 | # @example 247 | # dsl = AttributeDSL.new([]) 248 | # dsl.ungroup(tags: [:name]) 249 | # 250 | # @see AttributeDSL#embedded 251 | # 252 | # @api public 253 | def ungroup(*args, &block) 254 | with_name_or_options(*args) do |name, options, *| 255 | ungroup_options = { type: :array, ungroup: true }.update(options) 256 | dsl(name, ungroup_options, &block) 257 | end 258 | end 259 | 260 | # Define an embedded hash attribute that requires "fold" transformation 261 | # 262 | # Typically this is used in sql context to fold single joined field 263 | # to the array of values. 264 | # 265 | # @example 266 | # dsl = AttributeDSL.new([]) 267 | # 268 | # dsl.fold(tags: [:name]) 269 | # 270 | # @see AttributeDSL#embedded 271 | # 272 | # @api public 273 | def fold(*args, &block) 274 | with_name_or_options(*args) do |name, *| 275 | fold_options = { type: :array, fold: true } 276 | dsl(name, fold_options, &block) 277 | end 278 | end 279 | 280 | # Define an embedded hash attribute that requires "unfold" transformation 281 | # 282 | # Typically this is used in non-sql context to convert array of 283 | # values (like in Cassandra 'SET' or 'LIST' types) to array of tuples. 284 | # 285 | # Source values are assigned to the first key, the other keys being left blank. 286 | # 287 | # @example 288 | # dsl = AttributeDSL.new([]) 289 | # 290 | # dsl.unfold(tags: [:name, :type], from: :tags_list) 291 | # 292 | # dsl.unfold :tags, from: :tags_list do 293 | # attribute :name, from: :tag_name 294 | # attribute :type, from: :tag_type 295 | # end 296 | # 297 | # @see AttributeDSL#embedded 298 | # 299 | # @api public 300 | def unfold(name, options = EMPTY_HASH) 301 | with_attr_options(name, options) do |attr_options| 302 | old_name = attr_options.fetch(:from, name) 303 | dsl(old_name, type: :array, unfold: true) do 304 | attribute name, attr_options 305 | yield if block_given? 306 | end 307 | end 308 | end 309 | 310 | # Define an embedded combined attribute that requires "combine" transformation 311 | # 312 | # Typically this can be used to process results of eager-loading 313 | # 314 | # @example 315 | # dsl = AttributeDSL.new([]) 316 | # 317 | # dsl.combine(:tags, user_id: :id) do 318 | # model Tag 319 | # 320 | # attribute :name 321 | # end 322 | # 323 | # @param [Symbol] name 324 | # @param [Hash] options 325 | # @option options [Hash] :on The "join keys" 326 | # @option options [Symbol] :type The type, either :array (default) or :hash 327 | # 328 | # @api public 329 | def combine(name, options, &block) 330 | dsl = new(options, &block) 331 | 332 | attr_opts = { 333 | type: options.fetch(:type, :array), 334 | keys: options.fetch(:on), 335 | combine: true, 336 | header: dsl.header 337 | } 338 | 339 | add_attribute(name, attr_opts) 340 | end 341 | 342 | # Generate a header from attribute definitions 343 | # 344 | # @return [Header] 345 | # 346 | # @api private 347 | def header 348 | Header.coerce(attributes, copy_keys: copy_keys, model: model, reject_keys: reject_keys) 349 | end 350 | 351 | private 352 | 353 | # Remove the attribute used somewhere else (in wrap, group, model etc.) 354 | # 355 | # @api private 356 | def remove(*names) 357 | attributes.delete_if { |attr| names.include?(attr.first) } 358 | end 359 | 360 | # Handle attribute options common for all definitions 361 | # 362 | # @api private 363 | def with_attr_options(name, options = EMPTY_HASH) 364 | attr_options = options.dup 365 | 366 | if @prefix 367 | attr_options[:from] ||= "#{@prefix}#{@prefix_separator}#{name}" 368 | attr_options[:from] = attr_options[:from].to_sym if name.is_a? Symbol 369 | end 370 | 371 | if symbolize_keys 372 | attr_options.update(from: attr_options.fetch(:from) { name }.to_s) 373 | end 374 | 375 | yield(attr_options) 376 | end 377 | 378 | # Handle "name or options" syntax used by `wrap` and `group` 379 | # 380 | # @api private 381 | def with_name_or_options(*args) 382 | name, options = 383 | if args.size > 1 384 | args 385 | else 386 | [args.first, {}] 387 | end 388 | 389 | yield(name, options, options[:mapper]) 390 | end 391 | 392 | # Create another instance of the dsl for nested definitions 393 | # 394 | # This is used by embedded, wrap and group 395 | # 396 | # @api private 397 | def dsl(name_or_attrs, options, &block) 398 | if block 399 | attributes_from_block(name_or_attrs, options, &block) 400 | else 401 | attributes_from_hash(name_or_attrs, options) 402 | end 403 | end 404 | 405 | # Define attributes from a nested block 406 | # 407 | # Used by embedded, wrap and group 408 | # 409 | # @api private 410 | def attributes_from_block(name, options, &block) 411 | dsl = new(options, &block) 412 | header = dsl.header 413 | add_attribute(name, options.update(header: header)) 414 | header.each { |attr| remove(attr.key) unless name == attr.key } 415 | end 416 | 417 | # Define attributes from the `name => attributes` hash syntax 418 | # 419 | # Used by wrap and group 420 | # 421 | # @api private 422 | def attributes_from_hash(hash, options) 423 | hash.each do |name, header| 424 | with_attr_options(name, options) do |attr_options| 425 | add_attribute(name, attr_options.update(header: header.zip)) 426 | header.each { |attr| remove(attr) unless name == attr } 427 | end 428 | end 429 | end 430 | 431 | # Infer mapper header for an embedded attribute 432 | # 433 | # @api private 434 | def attributes_from_mapper(mapper, name, options) 435 | if mapper.is_a?(Class) 436 | add_attribute(name, { header: mapper.header }.update(options)) 437 | else 438 | raise( 439 | ArgumentError, ":mapper must be a class #{mapper.inspect}" 440 | ) 441 | end 442 | end 443 | 444 | # Add a new attribute and make sure it overrides previous definition 445 | # 446 | # @api private 447 | def add_attribute(name, options) 448 | remove(name, name.to_s) 449 | attributes << [name, options] 450 | end 451 | 452 | # Create a new dsl instance of potentially overidden options 453 | # 454 | # Embedded, wrap and group can override top-level options like `prefix` 455 | # 456 | # @api private 457 | def new(options, &block) 458 | dsl = self.class.new([], @options.merge(options)) 459 | dsl.instance_exec(&block) unless block.nil? 460 | dsl 461 | end 462 | 463 | # Ensure the mapping configuration isn't ambiguous 464 | # 465 | # @api private 466 | def ensure_mapper_configuration(method_name, args, block_present) 467 | if args.first.is_a?(Hash) && block_present 468 | raise MapperMisconfiguredError, 469 | "Cannot configure `#{method_name}#` using both options and a block" 470 | end 471 | if args.first.is_a?(Hash) && args.first[:mapper] 472 | raise MapperMisconfiguredError, 473 | "Cannot configure `#{method_name}#` using both options and a mapper" 474 | end 475 | end 476 | end 477 | end 478 | end 479 | -------------------------------------------------------------------------------- /lib/rom/mapper/dsl.rb: -------------------------------------------------------------------------------- 1 | require 'dry/core/class_attributes' 2 | require 'rom/mapper/attribute_dsl' 3 | 4 | module ROM 5 | class Mapper 6 | # Mapper class-level DSL including Attribute DSL and Model DSL 7 | module DSL 8 | # Extend mapper class with macros and DSL methods 9 | # 10 | # @api private 11 | def self.included(klass) 12 | klass.extend(Dry::Core::ClassAttributes) 13 | klass.extend(ClassMethods) 14 | end 15 | 16 | # Class methods for all mappers 17 | # 18 | # @private 19 | module ClassMethods 20 | # Set base ivars for the mapper class 21 | # 22 | # @api private 23 | def inherited(klass) 24 | super 25 | 26 | klass.instance_variable_set('@attributes', nil) 27 | klass.instance_variable_set('@header', nil) 28 | klass.instance_variable_set('@dsl', nil) 29 | end 30 | 31 | # include a registered plugin in this mapper 32 | # 33 | # @param [Symbol] plugin 34 | # @param [Hash] options 35 | # @option options [Symbol] :adapter (:default) first adapter to check for plugin 36 | # 37 | # @api public 38 | def use(plugin, options = {}) 39 | adapter = options.fetch(:adapter, :default) 40 | 41 | ROM.plugin_registry.mappers.fetch(plugin, adapter).apply_to(self) 42 | end 43 | 44 | # Return base_relation used for creating mapper registry 45 | # 46 | # This is used to "gather" mappers under same root name 47 | # 48 | # @api private 49 | def base_relation 50 | if superclass.relation 51 | superclass.relation 52 | else 53 | relation 54 | end 55 | end 56 | 57 | # Return header of the mapper 58 | # 59 | # This is memoized so mutating mapper class won't have an effect wrt 60 | # header after it was initialized for the first time. 61 | # 62 | # TODO: freezing mapper class here is probably a good idea 63 | # 64 | # @api private 65 | def header 66 | @header ||= dsl.header 67 | end 68 | 69 | # @api private 70 | def respond_to_missing?(name, _include_private = false) 71 | dsl.respond_to?(name) || super 72 | end 73 | 74 | private 75 | 76 | # Return default Attribute DSL options based on settings of the mapper 77 | # class 78 | # 79 | # @api private 80 | def options 81 | { copy_keys: copy_keys, 82 | prefix: prefix, 83 | prefix_separator: prefix_separator, 84 | symbolize_keys: symbolize_keys, 85 | reject_keys: reject_keys } 86 | end 87 | 88 | # Return default attributes that might have been inherited from the 89 | # superclass 90 | # 91 | # @api private 92 | def attributes 93 | @attributes ||= 94 | if superclass.respond_to?(:attributes, true) && inherit_header 95 | superclass.attributes.dup 96 | else 97 | [] 98 | end 99 | end 100 | 101 | # Create the attribute DSL instance used by the mapper class 102 | # 103 | # @api private 104 | def dsl 105 | @dsl ||= AttributeDSL.new(attributes, options) 106 | end 107 | 108 | # Delegate Attribute DSL method to the dsl instance 109 | # 110 | # @api private 111 | def method_missing(name, *args, &block) 112 | if dsl.respond_to?(name) 113 | dsl.public_send(name, *args, &block) 114 | else 115 | super 116 | end 117 | end 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/rom/mapper/model_dsl.rb: -------------------------------------------------------------------------------- 1 | require 'rom/model_builder' 2 | 3 | module ROM 4 | class Mapper 5 | # Model DSL allows setting a model class 6 | # 7 | # @private 8 | module ModelDSL 9 | attr_reader :attributes, :builder, :klass 10 | 11 | DEFAULT_TYPE = :poro 12 | 13 | # Set or generate a model 14 | # 15 | # @example 16 | # class MyDefinition 17 | # include ROM::Mapper::ModelDSL 18 | # 19 | # def initialize 20 | # @attributes = [[:name], [:title]] 21 | # end 22 | # end 23 | # 24 | # definition = MyDefinition.new 25 | # 26 | # # just set a model constant 27 | # definition.model(User) 28 | # 29 | # # generate model class for the attributes 30 | # definition.model(name: 'User') 31 | # 32 | # @api public 33 | def model(options = nil) 34 | if options.is_a?(Class) 35 | @klass = options 36 | elsif options 37 | type = options.fetch(:type) { DEFAULT_TYPE } 38 | @builder = ModelBuilder[type].new(options) 39 | end 40 | 41 | build_class unless options 42 | end 43 | 44 | private 45 | 46 | # Build a model class using a specialized builder 47 | # 48 | # @api private 49 | def build_class 50 | return klass if klass 51 | included_attrs = attributes.reject do |_name, opts| 52 | opts && opts[:exclude] 53 | end 54 | builder.call(included_attrs.map(&:first)) if builder 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/rom/mapper/version.rb: -------------------------------------------------------------------------------- 1 | module ROM 2 | class Mapper 3 | VERSION = '0.5.1'.freeze 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/rom/model_builder.rb: -------------------------------------------------------------------------------- 1 | require 'dry/core/inflector' 2 | 3 | module ROM 4 | # Model builders can be used to build model classes for mappers 5 | # 6 | # This is used when you define a mapper and setup a model using :name option. 7 | # 8 | # @example 9 | # # this will define User model for you 10 | # class UserMapper < ROM::Mapper 11 | # model name: 'User' 12 | # attribute :id 13 | # attribute :name 14 | # end 15 | # 16 | # @private 17 | class ModelBuilder 18 | attr_reader :name 19 | 20 | attr_reader :const_name, :namespace, :klass 21 | 22 | # Return model builder subclass based on type 23 | # 24 | # @param [Symbol] type 25 | # 26 | # @return [Class] 27 | # 28 | # @api private 29 | def self.[](type) 30 | case type 31 | when :poro then PORO 32 | else 33 | raise ArgumentError, "#{type.inspect} is not a supported model type" 34 | end 35 | end 36 | 37 | # Build a model class 38 | # 39 | # @return [Class] 40 | # 41 | # @api private 42 | def self.call(*args) 43 | new(*args).call 44 | end 45 | 46 | # @api private 47 | def initialize(options = {}) 48 | @name = options[:name] 49 | 50 | if name 51 | parts = name.split('::') 52 | 53 | @const_name = parts.pop 54 | 55 | @namespace = 56 | if parts.any? 57 | Dry::Core::Inflector.constantize(parts.join('::')) 58 | else 59 | Object 60 | end 61 | end 62 | end 63 | 64 | # Define a model class constant 65 | # 66 | # @api private 67 | def define_const 68 | namespace.const_set(const_name, klass) 69 | end 70 | 71 | # Build a model class supporting specific attributes 72 | # 73 | # @return [Class] 74 | # 75 | # @api private 76 | def call(attrs) 77 | define_class(attrs) 78 | define_const if const_name 79 | @klass 80 | end 81 | 82 | # PORO model class builder 83 | # 84 | # @private 85 | class PORO < ModelBuilder 86 | def define_class(attrs) 87 | @klass = Class.new 88 | 89 | @klass.send(:attr_reader, *attrs) 90 | 91 | @klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1 92 | def initialize(params) 93 | #{attrs.map { |name| "@#{name} = params[:#{name}]" }.join("\n")} 94 | end 95 | RUBY 96 | 97 | self 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/rom/processor.rb: -------------------------------------------------------------------------------- 1 | require 'rom/mapper' 2 | 3 | module ROM 4 | # Abstract processor class 5 | # 6 | # Every ROM processor should inherit from this class 7 | # 8 | # @api public 9 | class Processor 10 | # Hook used to auto-register a processor class 11 | # 12 | # @api private 13 | def self.inherited(processor) 14 | Mapper.register_processor(processor) 15 | end 16 | 17 | # Required interface to be implemented by descendants 18 | # 19 | # @return [Processor] 20 | # 21 | # @abstract 22 | # 23 | # @api private 24 | def self.build 25 | raise NotImplementedError, "+build+ must be implemented" 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/rom/processor/transproc.rb: -------------------------------------------------------------------------------- 1 | require 'transproc/all' 2 | 3 | require 'rom/processor' 4 | 5 | module ROM 6 | class Processor 7 | # Data mapping transformer builder using Transproc 8 | # 9 | # This builds a transproc function that is used to map a whole relation 10 | # 11 | # @see https://github.com/solnic/transproc too 12 | # 13 | # @private 14 | class Transproc < Processor 15 | include ::Transproc::Composer 16 | 17 | module Functions 18 | extend ::Transproc::Registry 19 | 20 | import ::Transproc::Coercions 21 | import ::Transproc::ArrayTransformations 22 | import ::Transproc::HashTransformations 23 | import ::Transproc::ClassTransformations 24 | import ::Transproc::ProcTransformations 25 | INVALID_INJECT_UNION_VALUE = "%s attribute: block is required for :from with union value.".freeze 26 | 27 | def self.identity(tuple) 28 | tuple 29 | end 30 | 31 | def self.filter_empty(arr) 32 | arr.reject { |row| row.values.all?(&:nil?) } 33 | end 34 | 35 | def self.inject_union_value(tuple, name, keys, coercer) 36 | raise ROM::MapperMisconfiguredError, INVALID_INJECT_UNION_VALUE % [name] if !coercer 37 | 38 | values = tuple.values_at(*keys) 39 | result = coercer.call(*values) 40 | 41 | tuple.merge(name => result) 42 | end 43 | end 44 | 45 | # @return [Mapper] mapper that this processor belongs to 46 | # 47 | # @api private 48 | attr_reader :mapper 49 | 50 | # @return [Header] header from a mapper 51 | # 52 | # @api private 53 | attr_reader :header 54 | 55 | # @return [Class] model class from a mapper 56 | # 57 | # @api private 58 | attr_reader :model 59 | 60 | # @return [Hash] header's attribute mapping 61 | # 62 | # @api private 63 | attr_reader :mapping 64 | 65 | # @return [Proc] row-processing proc 66 | # 67 | # @api private 68 | attr_reader :row_proc 69 | 70 | # Build a transproc function from the header 71 | # 72 | # @param [ROM::Header] header 73 | # 74 | # @return [Transproc::Function] 75 | # 76 | # @api private 77 | def self.build(mapper, header) 78 | new(mapper, header).to_transproc 79 | end 80 | 81 | # @api private 82 | def initialize(mapper, header) 83 | @mapper = mapper 84 | @header = header 85 | @model = header.model 86 | @mapping = header.mapping 87 | initialize_row_proc 88 | end 89 | 90 | # Coerce mapper header to a transproc data mapping function 91 | # 92 | # @return [Transproc::Function] 93 | # 94 | # @api private 95 | def to_transproc 96 | compose(t(:identity)) do |ops| 97 | combined = header.combined 98 | ops << t(:combine, combined.map(&method(:combined_args))) if combined.any? 99 | ops << header.preprocessed.map { |attr| visit(attr, true) } 100 | ops << t(:map_array, row_proc) if row_proc 101 | ops << header.postprocessed.map { |attr| visit(attr, true) } 102 | end 103 | end 104 | 105 | private 106 | 107 | # Visit an attribute from the header 108 | # 109 | # This forwards to a specialized visitor based on the attribute type 110 | # 111 | # @param [Header::Attribute] attribute 112 | # @param [Array] args Allows to send `preprocess: true` 113 | # 114 | # @api private 115 | def visit(attribute, *args) 116 | type = attribute.class.name.split('::').last.downcase 117 | send("visit_#{type}", attribute, *args) 118 | end 119 | 120 | # Visit plain attribute 121 | # 122 | # It will call block transformation if it's used 123 | # 124 | # If it's a typed attribute a coercion transformation is added 125 | # 126 | # @param [Header::Attribute] attribute 127 | # 128 | # @api private 129 | def visit_attribute(attribute) 130 | coercer = attribute.meta[:coercer] 131 | if attribute.union? 132 | compose do |ops| 133 | ops << t(:inject_union_value, attribute.name, attribute.key, coercer) 134 | ops << t(:reject_keys, attribute.key) unless header.copy_keys 135 | end 136 | elsif coercer 137 | t(:map_value, attribute.name, t(:bind, mapper, coercer)) 138 | elsif attribute.typed? 139 | t(:map_value, attribute.name, t(:"to_#{attribute.type}")) 140 | end 141 | end 142 | 143 | # Visit hash attribute 144 | # 145 | # @param [Header::Attribute::Hash] attribute 146 | # 147 | # @api private 148 | def visit_hash(attribute) 149 | with_row_proc(attribute) do |row_proc| 150 | t(:map_value, attribute.name, row_proc) 151 | end 152 | end 153 | 154 | # Visit combined attribute 155 | # 156 | # @api private 157 | def visit_combined(attribute) 158 | op = with_row_proc(attribute) do |row_proc| 159 | array_proc = 160 | if attribute.type == :hash 161 | t(:map_array, row_proc) >> -> arr { arr.first } 162 | else 163 | t(:map_array, row_proc) 164 | end 165 | 166 | t(:map_value, attribute.name, array_proc) 167 | end 168 | 169 | if op 170 | op 171 | elsif attribute.type == :hash 172 | t(:map_value, attribute.name, -> arr { arr.first }) 173 | end 174 | end 175 | 176 | # Visit array attribute 177 | # 178 | # @param [Header::Attribute::Array] attribute 179 | # 180 | # @api private 181 | def visit_array(attribute) 182 | with_row_proc(attribute) do |row_proc| 183 | t(:map_value, attribute.name, t(:map_array, row_proc)) 184 | end 185 | end 186 | 187 | # Visit wrapped hash attribute 188 | # 189 | # :nest transformation is added to handle wrapping 190 | # 191 | # @param [Header::Attribute::Wrap] attribute 192 | # 193 | # @api private 194 | def visit_wrap(attribute) 195 | name = attribute.name 196 | keys = attribute.tuple_keys 197 | 198 | compose do |ops| 199 | ops << t(:nest, name, keys) 200 | ops << visit_hash(attribute) 201 | end 202 | end 203 | 204 | # Visit unwrap attribute 205 | # 206 | # :unwrap transformation is added to handle unwrapping 207 | # 208 | # @param [Header::Attributes::Unwrap] 209 | # 210 | # @api private 211 | def visit_unwrap(attribute) 212 | name = attribute.name 213 | keys = attribute.pop_keys 214 | 215 | compose do |ops| 216 | ops << visit_hash(attribute) 217 | ops << t(:unwrap, name, keys) 218 | end 219 | end 220 | 221 | # Visit group hash attribute 222 | # 223 | # :group transformation is added to handle grouping during preprocessing. 224 | # Otherwise we simply use array visitor for the attribute. 225 | # 226 | # @param [Header::Attribute::Group] attribute 227 | # @param [Boolean] preprocess true if we are building a relation preprocessing 228 | # function that is applied to the whole relation 229 | # 230 | # @api private 231 | def visit_group(attribute, preprocess = false) 232 | if preprocess 233 | name = attribute.name 234 | header = attribute.header 235 | keys = attribute.tuple_keys 236 | 237 | others = header.preprocessed 238 | 239 | compose do |ops| 240 | ops << t(:group, name, keys) 241 | ops << t(:map_array, t(:map_value, name, t(:filter_empty))) 242 | ops << others.map { |attr| 243 | t(:map_array, t(:map_value, name, visit(attr, true))) 244 | } 245 | end 246 | else 247 | visit_array(attribute) 248 | end 249 | end 250 | 251 | # Visit ungroup attribute 252 | # 253 | # :ungroup transforation is added to handle ungrouping during preprocessing. 254 | # Otherwise we simply use array visitor for the attribute. 255 | # 256 | # @param [Header::Attribute::Ungroup] attribute 257 | # @param [Boolean] preprocess true if we are building a relation preprocessing 258 | # function that is applied to the whole relation 259 | # 260 | # @api private 261 | def visit_ungroup(attribute, preprocess = false) 262 | if preprocess 263 | name = attribute.name 264 | header = attribute.header 265 | keys = attribute.pop_keys 266 | 267 | others = header.postprocessed 268 | 269 | compose do |ops| 270 | ops << others.map { |attr| 271 | t(:map_array, t(:map_value, name, visit(attr, true))) 272 | } 273 | ops << t(:ungroup, name, keys) 274 | end 275 | else 276 | visit_array(attribute) 277 | end 278 | end 279 | 280 | # Visit fold hash attribute 281 | # 282 | # :fold transformation is added to handle folding during preprocessing. 283 | # 284 | # @param [Header::Attribute::Fold] attribute 285 | # @param [Boolean] preprocess true if we are building a relation preprocessing 286 | # function that is applied to the whole relation 287 | # 288 | # @api private 289 | def visit_fold(attribute, preprocess = false) 290 | if preprocess 291 | name = attribute.name 292 | keys = attribute.tuple_keys 293 | 294 | compose do |ops| 295 | ops << t(:group, name, keys) 296 | ops << t(:map_array, t(:map_value, name, t(:filter_empty))) 297 | ops << t(:map_array, t(:fold, name, keys.first)) 298 | end 299 | end 300 | end 301 | 302 | # Visit unfold hash attribute 303 | # 304 | # :unfold transformation is added to handle unfolding during preprocessing. 305 | # 306 | # @param [Header::Attribute::Unfold] attribute 307 | # @param [Boolean] preprocess true if we are building a relation preprocessing 308 | # function that is applied to the whole relation 309 | # 310 | # @api private 311 | def visit_unfold(attribute, preprocess = false) 312 | if preprocess 313 | name = attribute.name 314 | header = attribute.header 315 | keys = attribute.pop_keys 316 | key = keys.first 317 | 318 | others = header.postprocessed 319 | 320 | compose do |ops| 321 | ops << others.map { |attr| 322 | t(:map_array, t(:map_value, name, visit(attr, true))) 323 | } 324 | ops << t(:map_array, t(:map_value, name, t(:insert_key, key))) 325 | ops << t(:map_array, t(:reject_keys, [key] - [name])) 326 | ops << t(:ungroup, name, [key]) 327 | end 328 | end 329 | end 330 | 331 | # Visit excluded attribute 332 | # 333 | # @param [Header::Attribute::Exclude] attribute 334 | # 335 | # @api private 336 | def visit_exclude(attribute) 337 | t(:reject_keys, [attribute.name]) 338 | end 339 | 340 | # @api private 341 | def combined_args(attribute) 342 | other = attribute.header.combined 343 | 344 | if other.any? 345 | children = other.map(&method(:combined_args)) 346 | [attribute.name, attribute.meta[:keys], children] 347 | else 348 | [attribute.name, attribute.meta[:keys]] 349 | end 350 | end 351 | 352 | # Build row_proc 353 | # 354 | # This transproc function is applied to each row in a dataset 355 | # 356 | # @api private 357 | def initialize_row_proc 358 | @row_proc = compose { |ops| 359 | alias_handler = header.copy_keys ? :copy_keys : :rename_keys 360 | process_header_keys(ops) 361 | 362 | ops << t(alias_handler, mapping) if header.aliased? 363 | ops << header.map { |attr| visit(attr) } 364 | ops << t(:constructor_inject, model) if model 365 | } 366 | end 367 | 368 | # Process row_proc header keys 369 | # 370 | # @api private 371 | def process_header_keys(ops) 372 | if header.reject_keys 373 | all_keys = header.tuple_keys + header.non_primitives.map(&:key) 374 | ops << t(:accept_keys, all_keys) 375 | end 376 | ops 377 | end 378 | 379 | # Yield row proc for a given attribute if any 380 | # 381 | # @param [Header::Attribute] attribute 382 | # 383 | # @api private 384 | def with_row_proc(attribute) 385 | row_proc = row_proc_from(attribute) 386 | yield(row_proc) if row_proc 387 | end 388 | 389 | # Build a row_proc from a given attribute 390 | # 391 | # This is used by embedded attribute visitors 392 | # 393 | # @api private 394 | def row_proc_from(attribute) 395 | new(mapper, attribute.header).row_proc 396 | end 397 | 398 | # Return a new instance of the processor 399 | # 400 | # @api private 401 | def new(*args) 402 | self.class.new(*args) 403 | end 404 | 405 | # @api private 406 | def t(*args) 407 | Functions[*args] 408 | end 409 | end 410 | end 411 | end 412 | -------------------------------------------------------------------------------- /rakelib/benchmark.rake: -------------------------------------------------------------------------------- 1 | desc "Run benchmarks (tweak count via COUNT envvar)" 2 | task :benchmark do 3 | FileList["benchmarks/**/*_bench.rb"].each do |bench| 4 | sh "ruby #{bench}" 5 | end 6 | end 7 | 8 | namespace :benchmark do 9 | desc "Verify benchmarks" 10 | task :verify do 11 | ENV['VERIFY'] = "true" 12 | ENV['COUNT'] = "1" 13 | Rake::Task[:benchmark].invoke 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /rakelib/mutant.rake: -------------------------------------------------------------------------------- 1 | desc "Run mutant against a specific subject" 2 | task :mutant do 3 | subject = ARGV.last 4 | if subject == 'mutant' 5 | abort "usage: rake mutant SUBJECT\nexample: rake mutant ROM::Header" 6 | else 7 | opts = { 8 | 'include' => 'lib', 9 | 'require' => 'rom', 10 | 'use' => 'rspec', 11 | 'ignore-subject' => "#{subject}#respond_to_missing?" 12 | }.to_a.map { |k, v| "--#{k} #{v}" }.join(' ') 13 | 14 | exec("bundle exec mutant #{opts} #{subject}") 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /rakelib/rubocop.rake: -------------------------------------------------------------------------------- 1 | begin 2 | require "rubocop/rake_task" 3 | 4 | Rake::Task[:default].enhance [:rubocop] 5 | 6 | RuboCop::RakeTask.new do |task| 7 | task.options << "--display-cop-names" 8 | end 9 | 10 | namespace :rubocop do 11 | desc 'Generate a configuration file acting as a TODO list.' 12 | task :auto_gen_config do 13 | exec "bundle exec rubocop --auto-gen-config" 14 | end 15 | end 16 | 17 | rescue LoadError 18 | end 19 | -------------------------------------------------------------------------------- /rom-mapper.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path('../lib/rom/mapper/version', __FILE__) 2 | 3 | Gem::Specification.new do |gem| 4 | gem.name = "rom-mapper" 5 | gem.description = "ROM mapper component" 6 | gem.summary = gem.description 7 | gem.authors = 'Piotr Solnica' 8 | gem.email = 'piotr.solnica@gmail.com' 9 | gem.homepage = 'http://rom-rb.org' 10 | gem.require_paths = [ 'lib' ] 11 | gem.version = ROM::Mapper::VERSION.dup 12 | gem.files = `git ls-files`.split("\n") 13 | gem.test_files = `git ls-files -- {spec}/*`.split("\n") 14 | gem.license = 'MIT' 15 | 16 | gem.add_dependency 'dry-equalizer', '~> 0.2' 17 | gem.add_dependency 'dry-core', '~> 0.2', '>= 0.2.3' 18 | gem.add_dependency 'transproc', '~> 1.0' 19 | 20 | gem.add_development_dependency 'rake', '~> 11.3' 21 | gem.add_development_dependency 'rspec', '~> 3.5' 22 | end 23 | -------------------------------------------------------------------------------- /spec/integration/mapper_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe ROM::Mapper do 4 | let(:mapper_klass) do 5 | Class.new(ROM::Mapper).tap do |mapper_klass| 6 | mapper_klass.class_eval(&mapper_body) 7 | end 8 | end 9 | let(:mapper) { mapper_klass.build } 10 | 11 | subject { mapper.call(tuples) } 12 | 13 | describe '.attribute' do 14 | context 'with block' do 15 | let(:tuples) { [{ key: 'bar' }] } 16 | let(:results) { [{ key: 'foo_bar' }] } 17 | let(:mapper_body) do 18 | proc do 19 | attribute(:key) { |key| [prefix, key].join('_') } 20 | 21 | def prefix 22 | 'foo' 23 | end 24 | end 25 | end 26 | 27 | it 'creates the attribute from the proc with the mapper as the binding' do 28 | is_expected.to match_array(results) 29 | end 30 | end 31 | 32 | context 'when copying aliased keys to multiple attributes' do 33 | let(:tuples) { [{ key: 'bar' }] } 34 | let(:results) { [{ key: 'bar', key2: 'bar', key3: 'bar' }] } 35 | let(:mapper_body) do 36 | proc do 37 | copy_keys true 38 | attribute([:key2, :key3], from: :key) 39 | end 40 | end 41 | 42 | it 'creates attributes by copying keys rather than renaming' do 43 | is_expected.to match_array(results) 44 | end 45 | end 46 | end 47 | 48 | describe '.embedded' do 49 | context 'with block' do 50 | let(:tuples) { [{ items: { key: 'bar' } }] } 51 | let(:results) { [{ items: { key: 'foo_bar' } }] } 52 | let(:mapper_body) do 53 | proc do 54 | embedded :items, type: :hash do 55 | attribute(:key) { |key| [prefix, key].join('_') } 56 | end 57 | 58 | def prefix 59 | 'foo' 60 | end 61 | end 62 | end 63 | 64 | it 'creates the attribute from the proc with the mapper as the binding' do 65 | is_expected.to match_array(results) 66 | end 67 | end 68 | end 69 | 70 | describe '.wrap' do 71 | context 'attribute with block' do 72 | let(:tuples) { [{ key: 'bar' }] } 73 | let(:results) { [{ items: { key: 'foo_bar' } }] } 74 | let(:mapper_body) do 75 | proc do 76 | wrap :items do 77 | attribute(:key) { |key| [prefix, key].join('_') } 78 | end 79 | 80 | def prefix 81 | 'foo' 82 | end 83 | end 84 | end 85 | 86 | it 'creates the attribute from the proc with the mapper as the binding' do 87 | is_expected.to match_array(results) 88 | end 89 | end 90 | end 91 | 92 | describe '.unwrap' do 93 | context 'attribute with block' do 94 | let(:tuples) { [{ items: { key: 'bar' } }] } 95 | let(:results) { [{ key: 'foo_bar' }] } 96 | let(:mapper_body) do 97 | proc do 98 | unwrap :items do 99 | attribute(:key) { |key| [prefix, key].join('_') } 100 | end 101 | 102 | def prefix 103 | 'foo' 104 | end 105 | end 106 | end 107 | 108 | it 'creates the attribute from the proc with the mapper as the binding' do 109 | is_expected.to match_array(results) 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | if RUBY_ENGINE == 'ruby' && ENV['COVERAGE'] == 'true' 2 | require 'yaml' 3 | rubies = YAML.load(File.read(File.join(__dir__, '..', '.travis.yml')))['rvm'] 4 | latest_mri = rubies.select { |v| v =~ /\A\d+\.\d+.\d+\z/ }.max 5 | 6 | if RUBY_VERSION == latest_mri 7 | require 'simplecov' 8 | SimpleCov.start do 9 | add_filter '/spec/' 10 | end 11 | end 12 | end 13 | 14 | # this is needed for guard to work, not sure why :( 15 | require "bundler" 16 | Bundler.setup 17 | 18 | require 'rom-mapper' 19 | 20 | begin 21 | require 'byebug' 22 | rescue LoadError 23 | end 24 | 25 | root = Pathname(__FILE__).dirname 26 | 27 | Dir[root.join('support/*.rb').to_s].each do |f| 28 | require f 29 | end 30 | Dir[root.join('shared/*.rb').to_s].each do |f| 31 | require f 32 | end 33 | 34 | # Namespace holding all objects created during specs 35 | module Test 36 | def self.remove_constants 37 | constants.each(&method(:remove_const)) 38 | end 39 | end 40 | 41 | def T(*args) 42 | ROM::Processor::Transproc::Functions[*args] 43 | end 44 | 45 | RSpec.configure do |config| 46 | config.after do 47 | Test.remove_constants 48 | end 49 | 50 | config.around do |example| 51 | ConstantLeakFinder.find(example) 52 | end 53 | 54 | config.disable_monkey_patching! 55 | 56 | config.warnings = true 57 | end 58 | -------------------------------------------------------------------------------- /spec/support/constant_leak_finder.rb: -------------------------------------------------------------------------------- 1 | # Finds leaking constants created during ROM specs 2 | module ConstantLeakFinder 3 | def self.find(example) 4 | constants = Object.constants 5 | 6 | example.run 7 | 8 | added_constants = (Object.constants - constants) 9 | added = added_constants.map(&Object.method(:const_get)) 10 | if added.any? { |mod| mod.ancestors.map(&:name).grep(/\AROM/).any? } 11 | raise "Leaking constants: #{added_constants.inspect}" 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/mutant.rb: -------------------------------------------------------------------------------- 1 | module Mutant 2 | class Selector 3 | # Expression based test selector 4 | class Expression < self 5 | def call(_subject) 6 | integration.all_tests 7 | end 8 | end # Expression 9 | end # Selector 10 | end # Mutant 11 | -------------------------------------------------------------------------------- /spec/unit/rom/mapper/dsl_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe ROM::Mapper do 4 | subject(:mapper) do 5 | klass = Class.new(parent) 6 | options.each do |k, v| 7 | klass.send(k, v) 8 | end 9 | klass 10 | end 11 | 12 | let(:parent) { Class.new(ROM::Mapper) } 13 | 14 | let(:options) { {} } 15 | let(:header) { mapper.header } 16 | 17 | let(:expected_header) { ROM::Header.coerce(attributes) } 18 | 19 | describe '#attribute' do 20 | context 'simple attribute' do 21 | let(:attributes) { [[:name]] } 22 | 23 | it 'adds an attribute for the header' do 24 | mapper.attribute :name 25 | 26 | expect(header).to eql(expected_header) 27 | end 28 | end 29 | 30 | context 'aliased attribute' do 31 | let(:attributes) { [[:name, from: :user_name]] } 32 | 33 | it 'adds an aliased attribute for the header' do 34 | mapper.attribute :name, from: :user_name 35 | 36 | expect(header).to eql(expected_header) 37 | end 38 | end 39 | 40 | context 'prefixed attribute' do 41 | let(:attributes) { [[:name, from: :user_name]] } 42 | let(:options) { { prefix: :user } } 43 | 44 | it 'adds an aliased attribute for the header using configured :prefix' do 45 | mapper.attribute :name 46 | 47 | expect(header).to eql(expected_header) 48 | end 49 | end 50 | 51 | context 'prefixed attribute using custom separator' do 52 | let(:attributes) { [[:name, from: :'u.name']] } 53 | let(:options) { { prefix: :u, prefix_separator: '.' } } 54 | 55 | it 'adds an aliased attribute for the header using configured :prefix' do 56 | mapper.attribute :name 57 | 58 | expect(header).to eql(expected_header) 59 | end 60 | end 61 | 62 | context 'symbolized attribute' do 63 | let(:attributes) { [[:name, from: 'name']] } 64 | let(:options) { { symbolize_keys: true } } 65 | 66 | it 'adds an attribute with symbolized alias' do 67 | mapper.attribute :name 68 | 69 | expect(header).to eql(expected_header) 70 | end 71 | end 72 | end 73 | 74 | describe 'copy_keys' do 75 | let(:attributes) { [[:name, type: :string]] } 76 | let(:options) { { copy_keys: true } } 77 | 78 | it 'sets copy_keys for the header' do 79 | mapper.copy_keys true 80 | mapper.attribute :name, type: :string 81 | 82 | expect(header).to eql(expected_header) 83 | end 84 | end 85 | 86 | describe 'reject_keys' do 87 | let(:attributes) { [[:name, type: :string]] } 88 | let(:options) { { reject_keys: true } } 89 | 90 | it 'sets reject_keys for the header' do 91 | mapper.reject_keys true 92 | mapper.attribute :name, type: :string 93 | 94 | expect(header).to eql(expected_header) 95 | end 96 | end 97 | 98 | describe 'overriding inherited attributes' do 99 | context 'when name matches' do 100 | let(:attributes) { [[:name, type: :string]] } 101 | 102 | it 'excludes the inherited attribute' do 103 | parent.attribute :name 104 | 105 | mapper.attribute :name, type: :string 106 | 107 | expect(header).to eql(expected_header) 108 | end 109 | end 110 | 111 | context 'when alias matches' do 112 | let(:attributes) { [[:name, from: 'name', type: :string]] } 113 | 114 | it 'excludes the inherited attribute' do 115 | parent.attribute 'name' 116 | 117 | mapper.attribute :name, from: 'name', type: :string 118 | 119 | expect(header).to eql(expected_header) 120 | end 121 | end 122 | 123 | context 'when name in a wrapped attribute matches' do 124 | let(:attributes) do 125 | [ 126 | [:city, type: :hash, wrap: true, header: [[:name, from: :city_name]]] 127 | ] 128 | end 129 | 130 | it 'excludes the inherited attribute' do 131 | parent.attribute :city_name 132 | 133 | mapper.wrap :city do 134 | attribute :name, from: :city_name 135 | end 136 | 137 | expect(header).to eql(expected_header) 138 | end 139 | end 140 | 141 | context 'when name in a grouped attribute matches' do 142 | let(:attributes) do 143 | [ 144 | [:tags, type: :array, group: true, header: [[:name, from: :tag_name]]] 145 | ] 146 | end 147 | 148 | it 'excludes the inherited attribute' do 149 | parent.attribute :tag_name 150 | 151 | mapper.group :tags do 152 | attribute :name, from: :tag_name 153 | end 154 | 155 | expect(header).to eql(expected_header) 156 | end 157 | end 158 | 159 | context 'when name in a hash attribute matches' do 160 | let(:attributes) do 161 | [ 162 | [:city, type: :hash, header: [[:name, from: :city_name]]] 163 | ] 164 | end 165 | 166 | it 'excludes the inherited attribute' do 167 | parent.attribute :city 168 | 169 | mapper.embedded :city, type: :hash do 170 | attribute :name, from: :city_name 171 | end 172 | 173 | expect(header).to eql(expected_header) 174 | end 175 | end 176 | 177 | context 'when name of an array attribute matches' do 178 | let(:attributes) do 179 | [ 180 | [:tags, type: :array, header: [[:name, from: :tag_name]]] 181 | ] 182 | end 183 | 184 | it 'excludes the inherited attribute' do 185 | parent.attribute :tags 186 | 187 | mapper.embedded :tags, type: :array do 188 | attribute :name, from: :tag_name 189 | end 190 | 191 | expect(header).to eql(expected_header) 192 | end 193 | end 194 | end 195 | 196 | describe '#exclude' do 197 | let(:attributes) { [[:name, from: 'name']] } 198 | 199 | it 'removes an attribute from the inherited header' do 200 | mapper.attribute :name, from: 'name' 201 | expect(header).to eql(expected_header) 202 | end 203 | end 204 | 205 | describe '#embedded' do 206 | context 'when :type is set to :hash' do 207 | let(:attributes) { [[:city, type: :hash, header: [[:name]]]] } 208 | 209 | it 'adds an embedded hash attribute' do 210 | mapper.embedded :city, type: :hash do 211 | attribute :name 212 | end 213 | 214 | expect(header).to eql(expected_header) 215 | end 216 | end 217 | 218 | context 'when :type is set to :array' do 219 | let(:attributes) { [[:tags, type: :array, header: [[:name]]]] } 220 | 221 | it 'adds an embedded array attribute' do 222 | mapper.embedded :tags, type: :array do 223 | attribute :name 224 | end 225 | 226 | expect(header).to eql(expected_header) 227 | end 228 | end 229 | end 230 | 231 | describe '#wrap' do 232 | let(:attributes) { [[:city, type: :hash, wrap: true, header: [[:name]]]] } 233 | 234 | it 'adds an wrapped hash attribute using a block to define attributes' do 235 | mapper.wrap :city do 236 | attribute :name 237 | end 238 | 239 | expect(header).to eql(expected_header) 240 | end 241 | 242 | it 'adds an wrapped hash attribute using a options define attributes' do 243 | mapper.wrap city: [:name] 244 | 245 | expect(header).to eql(expected_header) 246 | end 247 | 248 | it 'raises an exception when using a block and options to define attributes' do 249 | expect { 250 | mapper.wrap(city: [:name]) { attribute :other_name } 251 | }.to raise_error(ROM::MapperMisconfiguredError) 252 | end 253 | 254 | it 'raises an exception when using options and a mapper to define attributes' do 255 | task_mapper = Class.new(ROM::Mapper) { attribute :title } 256 | expect { 257 | mapper.wrap city: [:name], mapper: task_mapper 258 | }.to raise_error(ROM::MapperMisconfiguredError) 259 | end 260 | end 261 | 262 | describe '#group' do 263 | let(:attributes) { [[:tags, type: :array, group: true, header: [[:name]]]] } 264 | 265 | it 'adds a group attribute using a block to define attributes' do 266 | mapper.group :tags do 267 | attribute :name 268 | end 269 | 270 | expect(header).to eql(expected_header) 271 | end 272 | 273 | it 'adds a group attribute using a options define attributes' do 274 | mapper.group tags: [:name] 275 | 276 | expect(header).to eql(expected_header) 277 | end 278 | 279 | it 'raises an exception when using a block and options to define attributes' do 280 | expect { 281 | mapper.group(cities: [:name]) { attribute :other_name } 282 | }.to raise_error(ROM::MapperMisconfiguredError) 283 | end 284 | 285 | it 'raises an exception when using options and a mapper to define attributes' do 286 | task_mapper = Class.new(ROM::Mapper) { attribute :title } 287 | expect { 288 | mapper.group cities: [:name], mapper: task_mapper 289 | }.to raise_error(ROM::MapperMisconfiguredError) 290 | end 291 | end 292 | 293 | describe 'top-level :prefix option' do 294 | let(:options) do 295 | { prefix: :user } 296 | end 297 | 298 | context 'when no attribute overrides top-level setting' do 299 | let(:attributes) do 300 | [ 301 | [:name, from: :user_name], 302 | [:address, from: :user_address, type: :hash, header: [ 303 | [:city, from: :user_city]] 304 | ], 305 | [:contact, type: :hash, wrap: true, header: [ 306 | [:mobile, from: :user_mobile]] 307 | ], 308 | [:tasks, type: :array, group: true, header: [ 309 | [:title, from: :user_title]] 310 | ] 311 | ] 312 | end 313 | 314 | it 'sets aliased attributes using prefix automatically' do 315 | mapper.attribute :name 316 | 317 | mapper.embedded :address, type: :hash do 318 | attribute :city 319 | end 320 | 321 | mapper.wrap :contact do 322 | attribute :mobile 323 | end 324 | 325 | mapper.group :tasks do 326 | attribute :title 327 | end 328 | 329 | expect(header).to eql(expected_header) 330 | end 331 | end 332 | 333 | context 'when an attribute overrides top-level setting' do 334 | let(:attributes) do 335 | [ 336 | [:name, from: :user_name], 337 | [:birthday, from: :user_birthday, type: :hash, header: [ 338 | [:year, from: :bd_year], 339 | [:month, from: :bd_month], 340 | [:day, from: :bd_day]] 341 | ], 342 | [:address, from: :user_address, type: :hash, header: [[:city]]], 343 | [:contact, type: :hash, wrap: true, header: [ 344 | [:mobile, from: :contact_mobile]] 345 | ], 346 | [:tasks, type: :array, group: true, header: [ 347 | [:title, from: :task_title]] 348 | ] 349 | ] 350 | end 351 | 352 | it 'excludes from aliasing the ones which override it' do 353 | mapper.attribute :name 354 | 355 | mapper.embedded :birthday, type: :hash, prefix: :bd do 356 | attribute :year 357 | attribute :month 358 | attribute :day 359 | end 360 | 361 | mapper.embedded :address, type: :hash, prefix: false do 362 | attribute :city 363 | end 364 | 365 | mapper.wrap :contact, prefix: :contact do 366 | attribute :mobile 367 | end 368 | 369 | mapper.group :tasks, prefix: :task do 370 | attribute :title 371 | end 372 | 373 | expect(header).to eql(expected_header) 374 | end 375 | end 376 | end 377 | 378 | context 'reusing mappers' do 379 | describe '#group' do 380 | let(:task_mapper) do 381 | Class.new(ROM::Mapper) { attribute :title } 382 | end 383 | 384 | let(:attributes) do 385 | [ 386 | [:name], 387 | [:tasks, type: :array, group: true, header: task_mapper.header] 388 | ] 389 | end 390 | 391 | it 'uses other mapper header' do 392 | mapper.attribute :name 393 | mapper.group :tasks, mapper: task_mapper 394 | 395 | expect(header).to eql(expected_header) 396 | end 397 | end 398 | 399 | describe '#wrap' do 400 | let(:task_mapper) do 401 | Class.new(ROM::Mapper) { attribute :title } 402 | end 403 | 404 | let(:attributes) do 405 | [ 406 | [:name], 407 | [:task, type: :hash, wrap: true, header: task_mapper.header] 408 | ] 409 | end 410 | 411 | it 'uses other mapper header' do 412 | mapper.attribute :name 413 | mapper.wrap :task, mapper: task_mapper 414 | 415 | expect(header).to eql(expected_header) 416 | end 417 | end 418 | 419 | describe '#embedded' do 420 | let(:task_mapper) do 421 | Class.new(ROM::Mapper) { attribute :title } 422 | end 423 | 424 | let(:attributes) do 425 | [ 426 | [:name], 427 | [:task, type: :hash, header: task_mapper.header] 428 | ] 429 | end 430 | 431 | it 'uses other mapper header' do 432 | mapper.attribute :name 433 | mapper.embedded :task, mapper: task_mapper, type: :hash 434 | 435 | expect(header).to eql(expected_header) 436 | end 437 | end 438 | end 439 | 440 | describe '#combine' do 441 | let(:attributes) do 442 | [ 443 | [:title], 444 | [:tasks, combine: true, type: :array, header: [[:title]]] 445 | ] 446 | end 447 | 448 | it 'adds combine attributes' do 449 | mapper.attribute :title 450 | 451 | mapper.combine :tasks, on: { title: :title } do 452 | attribute :title 453 | end 454 | 455 | expect(header).to eql(expected_header) 456 | end 457 | 458 | it 'works without a block' do 459 | expected_header = ROM::Header.coerce( 460 | [ 461 | [:title], 462 | [:tasks, combine: true, type: :array, header: []] 463 | ] 464 | ) 465 | 466 | mapper.attribute :title 467 | 468 | mapper.combine :tasks, on: { title: :title } 469 | 470 | expect(header).to eql(expected_header) 471 | end 472 | end 473 | 474 | describe '#method_missing' do 475 | it 'responds to DSL methods' do 476 | expect(mapper).to respond_to(:attribute) 477 | end 478 | end 479 | end 480 | -------------------------------------------------------------------------------- /spec/unit/rom/mapper/model_dsl_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe ROM::Mapper::ModelDSL do 4 | describe '#model' do 5 | it 'calls the builder with non-excluded attributes only' do 6 | definition_class = Class.new do 7 | include ROM::Mapper::ModelDSL 8 | 9 | def initialize 10 | @attributes = [[:name], [:title, { exclude: true }]] 11 | @builder = ->(attrs) { Struct.new(*attrs) } 12 | end 13 | end 14 | model_instance = definition_class.new.model.new 15 | expect(model_instance).to respond_to(:name) 16 | expect(model_instance).to_not respond_to(:title) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/unit/rom/mapper_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'ostruct' 4 | 5 | RSpec.describe ROM::Mapper do 6 | subject(:mapper) { mapper_class.build } 7 | 8 | let(:mapper_class) do 9 | user_model = self.user_model 10 | 11 | Class.new(ROM::Mapper) do 12 | attribute :id 13 | attribute :name 14 | model user_model 15 | end 16 | end 17 | 18 | let(:relation) do 19 | [{ id: 1, name: 'Jane' }, { id: 2, name: 'Joe' }] 20 | end 21 | 22 | let(:user_model) do 23 | Class.new(OpenStruct) { include Equalizer.new(:id, :name) } 24 | end 25 | 26 | let(:jane) { user_model.new(id: 1, name: 'Jane') } 27 | let(:joe) { user_model.new(id: 2, name: 'Joe') } 28 | 29 | describe '.registry' do 30 | it 'builds mapper class registry for base and virtual relations' do 31 | users = Class.new(ROM::Mapper) { relation(:users) } 32 | entity = Class.new(ROM::Mapper) do 33 | relation(:users) 34 | register_as(:entity) 35 | end 36 | active = Class.new(users) { relation(:active) } 37 | admins = Class.new(users) { relation(:admins) } 38 | custom = Class.new(users) { register_as(:custom) } 39 | 40 | registry = ROM::Mapper.registry([users, entity, active, admins, custom]) 41 | 42 | expect(registry).to eql( 43 | users: { 44 | users: users.build, 45 | entity: entity.build, 46 | active: active.build, 47 | admins: admins.build, 48 | custom: custom.build 49 | } 50 | ) 51 | end 52 | end 53 | 54 | describe '.relation' do 55 | it 'inherits from parent' do 56 | base = Class.new(ROM::Mapper) { relation(:users) } 57 | virt = Class.new(base) 58 | 59 | expect(virt.relation).to be(:users) 60 | expect(virt.base_relation).to be(:users) 61 | end 62 | 63 | it 'allows overriding' do 64 | base = Class.new(ROM::Mapper) { relation(:users) } 65 | virt = Class.new(base) { relation(:active) } 66 | 67 | expect(virt.relation).to be(:active) 68 | expect(virt.base_relation).to be(:users) 69 | end 70 | end 71 | 72 | describe "#each" do 73 | it "yields all mapped objects" do 74 | result = [] 75 | 76 | mapper.call(relation).each do |tuple| 77 | result << tuple 78 | end 79 | 80 | expect(result).to eql([jane, joe]) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/unit/rom/processor/transproc_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'virtus' 3 | 4 | RSpec.describe ROM::Processor::Transproc do 5 | subject(:transproc) { ROM::Processor::Transproc.build(binding, header) } 6 | 7 | let(:binding) { nil } 8 | let(:header) { ROM::Header.coerce(attributes, options) } 9 | let(:options) { {} } 10 | 11 | context 'no mapping' do 12 | let(:attributes) { [[:name]] } 13 | let(:relation) { [{ name: 'Jane' }, { name: 'Joe' }] } 14 | 15 | it 'returns tuples' do 16 | expect(transproc[relation]).to eql(relation) 17 | end 18 | end 19 | 20 | context 'coercing values' do 21 | let(:attributes) { [[:name, type: :string], [:age, type: :integer]] } 22 | let(:relation) { [{ name: :Jane, age: '1' }, { name: :Joe, age: '2' }] } 23 | 24 | it 'returns tuples' do 25 | expect(transproc[relation]).to eql([ 26 | { name: 'Jane', age: 1 }, { name: 'Joe', age: 2 } 27 | ]) 28 | end 29 | end 30 | 31 | context 'mapping to object' do 32 | let(:options) { { model: model } } 33 | 34 | let(:model) do 35 | Class.new do 36 | include Virtus.value_object 37 | values { attribute :name } 38 | end 39 | end 40 | 41 | let(:attributes) { [[:name]] } 42 | let(:relation) { [{ name: 'Jane' }, { name: 'Joe' }] } 43 | 44 | it 'returns tuples' do 45 | expect(transproc[relation]).to eql([ 46 | model.new(name: 'Jane'), model.new(name: 'Joe') 47 | ]) 48 | end 49 | end 50 | 51 | context 'renaming keys' do 52 | let(:attributes) do 53 | [[:name, from: 'name']] 54 | end 55 | 56 | let(:options) do 57 | { reject_keys: true } 58 | end 59 | 60 | let(:relation) do 61 | [ 62 | { 'name' => 'Jane', 'age' => 21 }, { 'name' => 'Joe', age: 22 } 63 | ] 64 | end 65 | 66 | it 'returns tuples with rejected keys' do 67 | expect(transproc[relation]).to eql([{ name: 'Jane' }, { name: 'Joe' }]) 68 | end 69 | end 70 | 71 | context 'copying keys' do 72 | let(:options) do 73 | { copy_keys: true } 74 | end 75 | 76 | let(:attributes) do 77 | [['b', from: 'a'], ['c', from: 'b']] 78 | end 79 | 80 | let(:relation) do 81 | [{ 'a' => 'copy' }] 82 | end 83 | 84 | it 'copies without removing the original' do 85 | expect(transproc[relation]).to eql([{ 'a' => 'copy', 'b' => 'copy', 'c' => 'copy' }]) 86 | end 87 | end 88 | 89 | context 'key from existing keys' do 90 | let(:attributes) do 91 | coercer = ->(a, b) { b + a } 92 | [[:c, { from: [:a, :b], coercer: coercer }]] 93 | end 94 | 95 | let(:relation) do 96 | [ 97 | { a: 'works', b: 'this' } 98 | ] 99 | end 100 | 101 | let(:expected_result) do 102 | [ 103 | { c: 'thisworks' } 104 | ] 105 | end 106 | 107 | let(:copy_keys_expected_result) do 108 | [ 109 | { a: 'works', b: 'this', c: 'thisworks'} 110 | ] 111 | end 112 | 113 | it 'returns tuples a new key added based on exsiting keys' do 114 | expect(transproc[relation]).to eql(expected_result) 115 | end 116 | 117 | it 'raises a configuration exception if coercer block does not exist' do 118 | attributes[0][1][:coercer] = nil 119 | expect { transproc[relation] }.to raise_error(ROM::MapperMisconfiguredError) 120 | end 121 | 122 | it 'honors the copy_keys option' do 123 | options.merge!({ copy_keys: true }) 124 | expect(transproc[relation]).to eql(copy_keys_expected_result) 125 | end 126 | end 127 | 128 | describe 'rejecting keys' do 129 | let(:options) { { reject_keys: true } } 130 | 131 | let(:attributes) do 132 | [ 133 | ['name'], 134 | ['tasks', type: :array, group: true, header: [['title']]] 135 | ] 136 | end 137 | 138 | let(:relation) do 139 | [ 140 | { 'name' => 'Jane', 'age' => 21, 'title' => 'Task One' }, 141 | { 'name' => 'Jane', 'age' => 21, 'title' => 'Task Two' }, 142 | { 'name' => 'Joe', 'age' => 22, 'title' => 'Task One' } 143 | ] 144 | end 145 | 146 | it 'returns tuples with unknown keys rejected' do 147 | expect(transproc[relation]).to eql([ 148 | { 'name' => 'Jane', 149 | 'tasks' => [{ 'title' => 'Task One' }, { 'title' => 'Task Two' }] }, 150 | { 'name' => 'Joe', 151 | 'tasks' => [{ 'title' => 'Task One' }] } 152 | ]) 153 | end 154 | end 155 | 156 | context 'mapping nested hash' do 157 | let(:relation) do 158 | [ 159 | { 'name' => 'Jane', 'task' => { 'title' => 'Task One' } }, 160 | { 'name' => 'Joe', 'task' => { 'title' => 'Task Two' } } 161 | ] 162 | end 163 | 164 | context 'when no mapping is needed' do 165 | let(:attributes) { [['name'], ['task', type: :hash, header: [[:title]]]] } 166 | 167 | it 'returns tuples' do 168 | expect(transproc[relation]).to eql(relation) 169 | end 170 | end 171 | 172 | context 'with deeply nested hashes' do 173 | context 'when no renaming is required' do 174 | let(:relation) do 175 | [ 176 | { 'user' => { 'name' => 'Jane', 'task' => { 'title' => 'Task One' } } }, 177 | { 'user' => { 'name' => 'Joe', 'task' => { 'title' => 'Task Two' } } } 178 | ] 179 | end 180 | 181 | let(:attributes) do 182 | [[ 183 | 'user', type: :hash, header: [ 184 | ['name'], 185 | ['task', type: :hash, header: [['title']]] 186 | ] 187 | ]] 188 | end 189 | 190 | it 'returns tuples' do 191 | expect(transproc[relation]).to eql(relation) 192 | end 193 | end 194 | 195 | context 'when renaming is required' do 196 | let(:relation) do 197 | [ 198 | { user: { name: 'Jane', task: { title: 'Task One' } } }, 199 | { user: { name: 'Joe', task: { title: 'Task Two' } } } 200 | ] 201 | end 202 | 203 | let(:attributes) do 204 | [[ 205 | 'user', type: :hash, header: [ 206 | ['name'], 207 | ['task', type: :hash, header: [['title']]] 208 | ] 209 | ]] 210 | end 211 | 212 | it 'returns tuples' do 213 | expect(transproc[relation]).to eql(relation) 214 | end 215 | end 216 | end 217 | 218 | context 'renaming keys' do 219 | context 'when only hash needs renaming' do 220 | let(:attributes) do 221 | [ 222 | ['name'], 223 | [:task, from: 'task', type: :hash, header: [[:title, from: 'title']]] 224 | ] 225 | end 226 | 227 | it 'returns tuples with key renamed in the nested hash' do 228 | expect(transproc[relation]).to eql([ 229 | { 'name' => 'Jane', :task => { title: 'Task One' } }, 230 | { 'name' => 'Joe', :task => { title: 'Task Two' } } 231 | ]) 232 | end 233 | end 234 | 235 | context 'when all attributes need renaming' do 236 | let(:attributes) do 237 | [ 238 | [:name, from: 'name'], 239 | [:task, from: 'task', type: :hash, header: [[:title, from: 'title']]] 240 | ] 241 | end 242 | 243 | it 'returns tuples with key renamed in the nested hash' do 244 | expect(transproc[relation]).to eql([ 245 | { name: 'Jane', task: { title: 'Task One' } }, 246 | { name: 'Joe', task: { title: 'Task Two' } } 247 | ]) 248 | end 249 | end 250 | end 251 | end 252 | 253 | context 'wrapping tuples' do 254 | let(:relation) do 255 | [ 256 | { 'name' => 'Jane', 'title' => 'Task One' }, 257 | { 'name' => 'Joe', 'title' => 'Task Two' } 258 | ] 259 | end 260 | 261 | context 'when no mapping is needed' do 262 | let(:attributes) do 263 | [ 264 | ['name'], 265 | ['task', type: :hash, wrap: true, header: [['title']]] 266 | ] 267 | end 268 | 269 | it 'returns wrapped tuples' do 270 | expect(transproc[relation]).to eql([ 271 | { 'name' => 'Jane', 'task' => { 'title' => 'Task One' } }, 272 | { 'name' => 'Joe', 'task' => { 'title' => 'Task Two' } } 273 | ]) 274 | end 275 | end 276 | 277 | context 'with deeply wrapped tuples' do 278 | let(:attributes) do 279 | [ 280 | ['user', type: :hash, wrap: true, header: [ 281 | ['name'], 282 | ['task', type: :hash, wrap: true, header: [['title']]] 283 | ]] 284 | ] 285 | end 286 | 287 | it 'returns wrapped tuples' do 288 | expect(transproc[relation]).to eql([ 289 | { 'user' => { 'name' => 'Jane', 'task' => { 'title' => 'Task One' } } }, 290 | { 'user' => { 'name' => 'Joe', 'task' => { 'title' => 'Task Two' } } } 291 | ]) 292 | end 293 | end 294 | 295 | context 'renaming keys' do 296 | context 'when only wrapped tuple requires renaming' do 297 | let(:attributes) do 298 | [ 299 | ['name'], 300 | ['task', type: :hash, wrap: true, header: [[:title, from: 'title']]] 301 | ] 302 | end 303 | 304 | it 'returns wrapped tuples with renamed keys' do 305 | expect(transproc[relation]).to eql([ 306 | { 'name' => 'Jane', 'task' => { title: 'Task One' } }, 307 | { 'name' => 'Joe', 'task' => { title: 'Task Two' } } 308 | ]) 309 | end 310 | end 311 | 312 | context 'when all attributes require renaming' do 313 | let(:attributes) do 314 | [ 315 | [:name, from: 'name'], 316 | [:task, type: :hash, wrap: true, header: [[:title, from: 'title']]] 317 | ] 318 | end 319 | 320 | it 'returns wrapped tuples with all keys renamed' do 321 | expect(transproc[relation]).to eql([ 322 | { name: 'Jane', task: { title: 'Task One' } }, 323 | { name: 'Joe', task: { title: 'Task Two' } } 324 | ]) 325 | end 326 | end 327 | end 328 | end 329 | 330 | context 'unwrapping tuples' do 331 | let(:relation) do 332 | [ 333 | { 'user' => { 'name' => 'Leo', 'task' => { 'title' => 'Task 1' } } }, 334 | { 'user' => { 'name' => 'Joe', 'task' => { 'title' => 'Task 2' } } } 335 | ] 336 | end 337 | 338 | context 'when no mapping is needed' do 339 | let(:attributes) do 340 | [ 341 | ['user', type: :hash, unwrap: true, header: [['name'], ['task']]] 342 | ] 343 | end 344 | 345 | it 'returns unwrapped tuples' do 346 | expect(transproc[relation]).to eql([ 347 | { 'name' => 'Leo', 'task' => { 'title' => 'Task 1' } }, 348 | { 'name' => 'Joe', 'task' => { 'title' => 'Task 2' } } 349 | ]) 350 | end 351 | end 352 | 353 | context 'partially' do 354 | context 'without renaming the rest of the wrap' do 355 | let(:attributes) do 356 | [ 357 | ['user', type: :hash, unwrap: true, header: [['task']]] 358 | ] 359 | end 360 | 361 | it 'returns unwrapped tuples' do 362 | expect(transproc[relation]).to eql([ 363 | { 'user' => { 'name' => 'Leo' }, 'task' => { 'title' => 'Task 1' } }, 364 | { 'user' => { 'name' => 'Joe' }, 'task' => { 'title' => 'Task 2' } } 365 | ]) 366 | end 367 | end 368 | 369 | context 'with renaming the rest of the wrap' do 370 | let(:attributes) do 371 | [ 372 | ['man', from: 'user', type: :hash, unwrap: true, header: [['task']]] 373 | ] 374 | end 375 | 376 | it 'returns unwrapped tuples' do 377 | expect(transproc[relation]).to eql([ 378 | { 'man' => { 'name' => 'Leo' }, 'task' => { 'title' => 'Task 1' } }, 379 | { 'man' => { 'name' => 'Joe' }, 'task' => { 'title' => 'Task 2' } } 380 | ]) 381 | end 382 | end 383 | end 384 | 385 | context 'deeply' do 386 | let(:attributes) do 387 | [ 388 | ['user', type: :hash, unwrap: true, header: [ 389 | ['name'], 390 | ['title'], 391 | ['task', type: :hash, unwrap: true, header: [['title']]] 392 | ]] 393 | ] 394 | end 395 | 396 | it 'returns unwrapped tuples' do 397 | expect(transproc[relation]).to eql([ 398 | { 'name' => 'Leo', 'title' => 'Task 1' }, 399 | { 'name' => 'Joe', 'title' => 'Task 2' } 400 | ]) 401 | end 402 | end 403 | end 404 | 405 | context 'grouping tuples' do 406 | let(:relation) do 407 | [ 408 | { 'name' => 'Jane', 'title' => 'Task One' }, 409 | { 'name' => 'Jane', 'title' => 'Task Two' }, 410 | { 'name' => 'Joe', 'title' => 'Task One' }, 411 | { 'name' => 'Joe', 'title' => nil } 412 | ] 413 | end 414 | 415 | context 'when no mapping is needed' do 416 | let(:attributes) do 417 | [ 418 | ['name'], 419 | ['tasks', type: :array, group: true, header: [['title']]] 420 | ] 421 | end 422 | 423 | it 'returns wrapped tuples with all keys renamed' do 424 | expect(transproc[relation]).to eql([ 425 | { 'name' => 'Jane', 426 | 'tasks' => [{ 'title' => 'Task One' }, { 'title' => 'Task Two' }] }, 427 | { 'name' => 'Joe', 428 | 'tasks' => [{ 'title' => 'Task One' }] } 429 | ]) 430 | end 431 | end 432 | 433 | context 'renaming keys' do 434 | context 'when only grouped tuple requires renaming' do 435 | let(:attributes) do 436 | [ 437 | ['name'], 438 | ['tasks', type: :array, group: true, header: [[:title, from: 'title']]] 439 | ] 440 | end 441 | 442 | it 'returns grouped tuples with renamed keys' do 443 | expect(transproc[relation]).to eql([ 444 | { 'name' => 'Jane', 445 | 'tasks' => [{ title: 'Task One' }, { title: 'Task Two' }] }, 446 | { 'name' => 'Joe', 447 | 'tasks' => [{ title: 'Task One' }] } 448 | ]) 449 | end 450 | end 451 | 452 | context 'when all attributes require renaming' do 453 | let(:attributes) do 454 | [ 455 | [:name, from: 'name'], 456 | [:tasks, type: :array, group: true, header: [[:title, from: 'title']]] 457 | ] 458 | end 459 | 460 | it 'returns grouped tuples with all keys renamed' do 461 | expect(transproc[relation]).to eql([ 462 | { name: 'Jane', 463 | tasks: [{ title: 'Task One' }, { title: 'Task Two' }] }, 464 | { name: 'Joe', 465 | tasks: [{ title: 'Task One' }] } 466 | ]) 467 | end 468 | end 469 | end 470 | 471 | context 'nested grouping' do 472 | let(:relation) do 473 | [ 474 | { name: 'Jane', title: 'Task One', tag: 'red' }, 475 | { name: 'Jane', title: 'Task One', tag: 'green' }, 476 | { name: 'Joe', title: 'Task One', tag: 'blue' } 477 | ] 478 | end 479 | 480 | let(:attributes) do 481 | [ 482 | [:name], 483 | [:tasks, type: :array, group: true, header: [ 484 | [:title], 485 | [:tags, type: :array, group: true, header: [[:tag]]] 486 | ]] 487 | ] 488 | end 489 | 490 | it 'returns deeply grouped tuples' do 491 | expect(transproc[relation]).to eql([ 492 | { name: 'Jane', 493 | tasks: [ 494 | { title: 'Task One', tags: [{ tag: 'red' }, { tag: 'green' }] } 495 | ] 496 | }, 497 | { name: 'Joe', 498 | tasks: [ 499 | { title: 'Task One', tags: [{ tag: 'blue' }] } 500 | ] 501 | } 502 | ]) 503 | end 504 | end 505 | end 506 | end 507 | --------------------------------------------------------------------------------