model-mapper 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d815217a42e717db82be8fb900d733a5b74447fefb0276afaa6bee4e0a8bc1ca
4
- data.tar.gz: 3c46647c66ce79bd951883461c283372f195d95a0addc65208390bb7fad940a3
3
+ metadata.gz: 775e838aa683bcb13a626497d487253ea1e23c27eef15df92a92d99ffdad19f8
4
+ data.tar.gz: 39b2177d2a088282f16135540f0cbfd9fef501699981bb358981e96b0f6ebdb8
5
5
  SHA512:
6
- metadata.gz: 8a2b61232b59e1fc1626a7a68cd1f52e9b14851e8252211af28419eb7b18b0f4e37be2d4150d98f39ecafae66b66a50037dcca24043565295b49ac823a3bad24
7
- data.tar.gz: 392885dc229cc27060917c74c1aaeb6aca4f197366477993c60933fc2c05d4f115e91384ff22b17c08ba343c7a1b1bc62b98f8677bca6b8f36213f27bb364229
6
+ metadata.gz: 0ec1989c92c08271125531e1809a845bae74d85b8d23276fd2ac084ed20db8ebcd80966efd44bc82c6264fbb326a92c26ca68abfa82b851fa73e3de0da54d784
7
+ data.tar.gz: 7179f959eccb52dee9d34ca3c8be8572b19f49b97b08c05e4366a889a8dd720a17b3d557fa93989b7610bb43e956eee28e8ef49d35cb607360281a413ef8d072
data/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/dougyouch/mappable/compare/v0.1.0...v0.2.0) (2026-10-05)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **mapping:** the internal Mapping instance methods map_data, skip?, get_value, call_method and call_map_method are removed.
9
+
10
+ ### Features
11
+
12
+ * **mappable:** add map_from_<name> to copy data back from the destination ([2b1503f](https://github.com/dougyouch/mappable/commit/2b1503f5a619b77f1d2e342ecf06d5ff429c1392))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **mappable:** support map_to on anonymous classes and stop modifying options ([a5b3b7f](https://github.com/dougyouch/mappable/commit/a5b3b7f1ea0b2477f2213cd3e9c28fcdecda0934))
18
+ * **utils:** keep capital letters when building mapping class names ([5160516](https://github.com/dougyouch/mappable/commit/5160516a1d10bd9cf8593a34496bf95ee13a3330))
19
+
20
+
21
+ ### Performance Improvements
22
+
23
+ * **mapping:** compile mappings into plain ruby methods ([8854abb](https://github.com/dougyouch/mappable/commit/8854abb763f976bbc76a4bd7cc00e9c1dde50908))
data/README.md CHANGED
@@ -1,5 +1,228 @@
1
- # mappable
1
+ # Mappable
2
2
 
3
- [![Build Status](https://travis-ci.org/dougyouch/mappable.svg?branch=master)](https://travis-ci.org/dougyouch/mappable)
4
- [![Maintainability](https://api.codeclimate.com/v1/badges/ac5801c8775694186c58/maintainability)](https://codeclimate.com/github/dougyouch/mappable/maintainability)
5
- [![Test Coverage](https://api.codeclimate.com/v1/badges/ac5801c8775694186c58/test_coverage)](https://codeclimate.com/github/dougyouch/mappable/test_coverage)
3
+ Fast, declarative two-way mapping between Ruby objects. Declare once how the fields of one object map to another, and Mappable compiles it into plain Ruby methods that copy the data in either direction at close to hand-written speed.
4
+
5
+ [![CI](https://github.com/dougyouch/mappable/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/dougyouch/mappable/actions/workflows/ci.yml)
6
+ [![Coverage](https://raw.githubusercontent.com/dougyouch/mappable/badges/coverage.svg)](https://github.com/dougyouch/mappable/actions/workflows/ci.yml)
7
+ [![Branch Coverage](https://raw.githubusercontent.com/dougyouch/mappable/badges/branches.svg)](https://github.com/dougyouch/mappable/actions/workflows/ci.yml)
8
+ [![Gem Version](https://badge.fury.io/rb/model-mapper.svg)](https://rubygems.org/gems/model-mapper)
9
+
10
+ [API reference](https://rubydoc.info/gems/model-mapper) · [Changelog](CHANGELOG.md) · [Architecture](ARCHITECTURE.md)
11
+
12
+ ## Installation
13
+
14
+ Requires Ruby 3.2 or newer. The gem is published as `model-mapper`:
15
+
16
+ ```ruby
17
+ gem 'model-mapper'
18
+ ```
19
+
20
+ ```ruby
21
+ require 'model-mapper' # Bundler does this for you
22
+ ```
23
+
24
+ ## Quick Start
25
+
26
+ Map a `User` to a `Contact`, combining `first_name` and `last_name` into `name`, and split `name` back out on the way back:
27
+
28
+ ```ruby
29
+ User = Struct.new(:first_name, :last_name, :email)
30
+ Contact = Struct.new(:name, :email_address)
31
+
32
+ class User
33
+ include Mappable
34
+
35
+ map_to(:contact) do
36
+ # user -> contact
37
+ custom_map(:name) { |user| "#{user.first_name} #{user.last_name}" }
38
+
39
+ # contact -> user
40
+ custom_map_back(:first_name) { |contact| contact.name.split(' ', 2).first }
41
+ custom_map_back(:last_name) { |contact| contact.name.split(' ', 2).last }
42
+
43
+ # both directions: email -> email_address and email_address -> email
44
+ map :email, :email_address
45
+ end
46
+ end
47
+
48
+ user = User.new('Ada', 'Lovelace', 'ada@example.com')
49
+
50
+ contact = user.map_to_contact(Contact.new)
51
+ contact.name # => "Ada Lovelace"
52
+ contact.email_address # => "ada@example.com"
53
+
54
+ copy = User.new.map_from_contact(contact)
55
+ copy.first_name # => "Ada"
56
+ copy.last_name # => "Lovelace"
57
+ copy.email # => "ada@example.com"
58
+ ```
59
+
60
+ `map_to(:contact)` creates a mapping class, `User::ContactMapping`, and two instance methods:
61
+
62
+ | Method | Does | Returns |
63
+ |---|---|---|
64
+ | `map_to_contact(dest)` | copies the user's fields to `dest` | `dest` |
65
+ | `map_from_contact(src)` | copies the fields of `src` back to the user | the user |
66
+
67
+ Any objects work as long as they have getters for the fields being read and setters (`name=`) for the fields being written: Structs, plain Ruby classes, ActiveRecord or ActiveModel models.
68
+
69
+ ## Declaring Mappings
70
+
71
+ ### `map`: copy a field, both ways
72
+
73
+ ```ruby
74
+ map :email # email -> email, and back
75
+ map :email, :email_address # email -> email_address, and email_address -> email
76
+ ```
77
+
78
+ ### `custom_map`: compute a field
79
+
80
+ `custom_map` sets a destination field from a block, or from a method on the mapping class. It's one-way; declare the reverse with `custom_map_back`.
81
+
82
+ ```ruby
83
+ map_to(:contact) do
84
+ custom_map(:name) { |user| "#{user.first_name} #{user.last_name}" }
85
+
86
+ custom_map :initials # calls initials(user)
87
+ custom_map :display_name, :full_name # calls full_name(user)
88
+
89
+ def initials(user)
90
+ "#{user.first_name[0]}#{user.last_name[0]}"
91
+ end
92
+
93
+ private
94
+
95
+ def full_name(user) # custom methods can be private
96
+ [user.first_name, user.last_name].compact.join(' ')
97
+ end
98
+ end
99
+ ```
100
+
101
+ ### `custom_map_back`: compute a field on the way back
102
+
103
+ `custom_map_back` sets a field on the source object from the destination object. It takes the same arguments as `custom_map`.
104
+
105
+ ```ruby
106
+ custom_map_back(:first_name) { |contact| contact.name.split(' ', 2).first }
107
+ custom_map_back :last_name # calls last_name(contact)
108
+ ```
109
+
110
+ ## Conditions
111
+
112
+ Skip a field unless a condition holds. Every mapping method takes these options:
113
+
114
+ | Option | Checked on |
115
+ |---|---|
116
+ | `if:` / `unless:` | the mapping instance |
117
+ | `if_src:` / `unless_src:` | the object being read from |
118
+ | `if_dest:` / `unless_dest:` | the object being written to |
119
+
120
+ A condition is a method name, called on that object, or a proc, run with that object as `self` and passed as its argument. A lambda that takes no arguments works too.
121
+
122
+ ```ruby
123
+ map_to(:contact) do
124
+ map :email, :email_address, if_src: :email_verified?
125
+ map :phone, unless_dest: :phone_locked?
126
+ map :notes, if: -> { include_notes }
127
+ map :status, unless_src: ->(user) { user.status.nil? }
128
+
129
+ attr_accessor :include_notes
130
+ end
131
+ ```
132
+
133
+ Each `map` call also declares the reverse mapping, and the reverse swaps `_src` and `_dest` conditions so they still check the same object. In the example above, `if_src: :email_verified?` checks the user in both directions: on the way to the contact the user is the source, and on the way back it's the destination.
134
+
135
+ When a field has several conditions, all of them must pass.
136
+
137
+ ## Using Mapping Classes Directly
138
+
139
+ The mapping class is a regular class you can instantiate, which is how you pass it state such as the `include_notes` flag above:
140
+
141
+ ```ruby
142
+ mapping = User::ContactMapping.new
143
+ mapping.include_notes = true
144
+ mapping.map(user, Contact.new) # => the contact
145
+ mapping.map_back(contact, User.new) # => the user
146
+ ```
147
+
148
+ You can also define mapping classes without `map_to`:
149
+
150
+ ```ruby
151
+ class ContactMapping
152
+ include Mappable::Mapping
153
+
154
+ map :email, :email_address
155
+ end
156
+
157
+ ContactMapping.new.map(user, Contact.new)
158
+ ```
159
+
160
+ ### `map_to` options
161
+
162
+ ```ruby
163
+ # name the mapping class (defaults to ContactMapping, set as a constant of the including class)
164
+ map_to(:contact, class_name: 'PersonMapper') { ... }
165
+
166
+ # inherit mappings from another mapping class and add to them
167
+ map_to(:admin_contact, base_class: User::ContactMapping) do
168
+ map :role
169
+ end
170
+ ```
171
+
172
+ A subclass gets the mappings its base class had when the subclass declared its first mapping. Declare a base class's mappings before you subclass it.
173
+
174
+ ### Inspecting mappings
175
+
176
+ `mappings` and `map_back_mappings` return the options for each field. Any extra options you pass, such as a description, are kept:
177
+
178
+ ```ruby
179
+ map :email, :email_address, description: 'primary email'
180
+
181
+ User::ContactMapping.mappings[:email_address]
182
+ # => {src: :email, getter: "email", dest: :email_address, setter: "email_address=", description: "primary email"}
183
+
184
+ User.maps # => {contact: User::ContactMapping}
185
+ ```
186
+
187
+ ## Performance
188
+
189
+ Mappable doesn't interpret the mapping hash at runtime. Each `map`, `custom_map` or `custom_map_back` call regenerates the mapping class's `map` and `map_back` methods as straight-line Ruby, so mapping an object is a list of getter and setter calls:
190
+
191
+ ```ruby
192
+ map :email, :email_address, if_dest: :persisted?
193
+ custom_map(:name) { |user| "#{user.first_name} #{user.last_name}" }
194
+
195
+ # compiles to
196
+ def map(src_model, dest_model)
197
+ dest_model.email_address = src_model.email if dest_model.persisted?
198
+ dest_model.name = MAPPABLE_PROCS[0].call(src_model)
199
+ dest_model
200
+ end
201
+ ```
202
+
203
+ Mapping 9 fields between Structs (one computed field, one condition), on Ruby 4.0.7 without YJIT:
204
+
205
+ | | ns per object |
206
+ |---|---|
207
+ | hand-written assignments | 490 |
208
+ | `map_to_contact` (this version) | 636 |
209
+ | `map_to_contact` (model-mapper 0.1.0) | 4560 |
210
+
211
+ Recompiling happens when mappings are declared, so declare them when your classes load, not per request.
212
+
213
+ ## Development
214
+
215
+ ```bash
216
+ bundle install
217
+ bundle exec rspec # tests, with line and branch coverage in coverage/
218
+ bundle exec rubocop # lint
219
+ bundle exec yard # API docs in doc/
220
+ ```
221
+
222
+ CI runs RuboCop, requires every public API to have YARD docs, and runs the specs on Ruby 3.2 and on the Ruby in `.ruby-version`, where it requires 100% line and branch coverage.
223
+
224
+ Releases are automated with [release-please](https://github.com/googleapis/release-please): [conventional commits](https://www.conventionalcommits.org/) on `master` keep a release PR up to date, and merging it tags the release and publishes the gem to RubyGems.
225
+
226
+ ## License
227
+
228
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mappable
4
+ # Class methods added by including {Mappable}
5
+ module ClassMethods
6
+ # @return [Hash{Symbol => Class}] mapping classes by name, as declared with {#map_to}
7
+ def maps
8
+ {}.freeze
9
+ end
10
+
11
+ # Creates a mapping class, a constant of this class, and two instance methods that use it:
12
+ #
13
+ # - `map_to_<name>(dest)` copies this object's fields to `dest` and returns `dest`
14
+ # - `map_from_<name>(src)` copies the fields of `src` back to this object and returns `self`
15
+ #
16
+ # @example
17
+ # class User
18
+ # include Mappable
19
+ #
20
+ # map_to(:contact) do
21
+ # map :email, :email_address
22
+ # end
23
+ # end
24
+ #
25
+ # user.map_to_contact(Contact.new) # => the contact, with email_address set
26
+ # User.new.map_from_contact(contact) # => the user, with email set
27
+ #
28
+ # @param name [Symbol, String] names the methods and the mapping class (`:contact` creates `ContactMapping`)
29
+ # @param options [Hash] see {Mapping.create}
30
+ # @yield evaluated in the mapping class, to declare its mappings (see {Mapping::ClassMethods})
31
+ # @return [Class] the mapping class
32
+ def map_to(name, options = {}, &)
33
+ mapping = Mapping.create(self, name, options, &)
34
+ add_value_to_class_method(:maps, name.to_sym => mapping)
35
+ # referenced by its constant name, resolved from this class, so anonymous classes work too
36
+ mapping_const = mapping.name.split('::').last
37
+
38
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
39
+ def map_to_#{name}(dest) # def map_to_contact(dest)
40
+ #{mapping_const}.new.map(self, dest) # ContactMapping.new.map(self, dest)
41
+ end # end
42
+
43
+ def map_from_#{name}(src) # def map_from_contact(src)
44
+ #{mapping_const}.new.map_back(src, self) # ContactMapping.new.map_back(src, self)
45
+ end # end
46
+ RUBY
47
+
48
+ mapping
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mappable
4
+ # Turns mapping options into the source of a Ruby method, so running a mapping is a
5
+ # series of plain method calls instead of hash lookups and `public_send`.
6
+ #
7
+ # @example
8
+ # # map :email, :email_address, if_dest: :persisted?
9
+ # # custom_map(:name) { |user| "#{user.first_name} #{user.last_name}" }
10
+ # #
11
+ # # compiles to
12
+ # def map(src_model, dest_model)
13
+ # dest_model.email_address = src_model.email if dest_model.persisted?
14
+ # dest_model.name = MAPPABLE_PROCS[0].call(src_model)
15
+ # dest_model
16
+ # end
17
+ #
18
+ # Procs can't be written into source, so they are collected in {#procs} and the
19
+ # generated code reads them from the {PROCS_CONSTANT} constant on the mapping class.
20
+ # Names that can't be called with dot syntax are called with `public_send`.
21
+ #
22
+ # @api private
23
+ class Compiler
24
+ # Name of the constant on the mapping class that holds {#procs}
25
+ PROCS_CONSTANT = :MAPPABLE_PROCS
26
+
27
+ # A method name that can be called with dot syntax
28
+ METHOD_NAME = /\A[a-zA-Z_]\w*[?!]?\z/
29
+
30
+ # A setter name that can be called with assignment syntax
31
+ SETTER_NAME = /\A[a-zA-Z_]\w*=\z/
32
+
33
+ # Condition options in the order they are checked: option, receiver, negated
34
+ CONDITIONS = [
35
+ [:if, 'self', false],
36
+ [:unless, 'self', true],
37
+ [:if_dest, 'dest_model', false],
38
+ [:unless_dest, 'dest_model', true],
39
+ [:if_src, 'src_model', false],
40
+ [:unless_src, 'src_model', true]
41
+ ].freeze
42
+
43
+ # @return [Array<Proc>] the procs referenced by the generated source, by index
44
+ attr_reader :procs
45
+
46
+ def initialize
47
+ @procs = []
48
+ end
49
+
50
+ # @param method_name [Symbol] name of the generated method
51
+ # @param mappings [Hash{Symbol => Hash}] mapping options, as built by {Mapping::ClassMethods}
52
+ # @return [String] source of a method that takes (src_model, dest_model) and returns dest_model
53
+ # @raise [ArgumentError] when a condition or map method is not a Symbol, String or Proc
54
+ def method_source(method_name, mappings)
55
+ lines = mappings.each_value.map { |options| " #{assignment(options)}" }
56
+ ["def #{method_name}(src_model, dest_model)", *lines, ' dest_model', 'end'].join("\n")
57
+ end
58
+
59
+ private
60
+
61
+ def assignment(options)
62
+ line = setter_call(options[:setter].to_s, value(options))
63
+ condition = condition(options)
64
+ condition ? "#{line} if #{condition}" : line
65
+ end
66
+
67
+ def value(options)
68
+ return method_call('src_model', options[:getter].to_s) unless options[:map_method]
69
+
70
+ map_method_call(options[:map_method])
71
+ end
72
+
73
+ def map_method_call(map_method)
74
+ case map_method
75
+ when Symbol, String
76
+ method_call('self', map_method.to_s, 'src_model')
77
+ when Proc
78
+ "#{proc_ref(map_method)}.call(src_model)"
79
+ else
80
+ raise ArgumentError, "map method must be a Symbol, String or Proc, got #{map_method.inspect}"
81
+ end
82
+ end
83
+
84
+ def condition(options)
85
+ checks = CONDITIONS.filter_map do |option, receiver, negated|
86
+ next unless options[option]
87
+
88
+ check = condition_call(receiver, options[option])
89
+ negated ? "!(#{check})" : check
90
+ end
91
+ checks.join(' && ') unless checks.empty?
92
+ end
93
+
94
+ # Proc conditions run with the receiver as self and get it as their argument;
95
+ # lambdas that take no arguments are called without it
96
+ def condition_call(receiver, condition)
97
+ case condition
98
+ when Symbol, String
99
+ method_call(receiver, condition.to_s)
100
+ when Proc
101
+ arg = condition.lambda? && condition.arity.zero? ? '' : "#{receiver}, "
102
+ "#{receiver}.instance_exec(#{arg}&#{proc_ref(condition)})"
103
+ else
104
+ raise ArgumentError, "condition must be a Symbol, String or Proc, got #{condition.inspect}"
105
+ end
106
+ end
107
+
108
+ # self.name(...) can call the mapping's private methods; other receivers only public ones
109
+ def method_call(receiver, name, *args)
110
+ call_args = args.empty? ? '' : "(#{args.join(', ')})"
111
+ return "#{receiver}.#{name}#{call_args}" if METHOD_NAME.match?(name)
112
+
113
+ send_method = receiver == 'self' ? '__send__' : 'public_send'
114
+ "#{receiver}.#{send_method}(#{[name.to_sym.inspect, *args].join(', ')})"
115
+ end
116
+
117
+ def setter_call(setter, value)
118
+ return "dest_model.#{setter.delete_suffix('=')} = #{value}" if SETTER_NAME.match?(setter)
119
+
120
+ "dest_model.public_send(#{setter.to_sym.inspect}, #{value})"
121
+ end
122
+
123
+ def proc_ref(proc)
124
+ @procs << proc
125
+ "#{PROCS_CONSTANT}[#{@procs.size - 1}]"
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mappable
4
+ module Mapping
5
+ # The mapping DSL, added to classes that include {Mapping}.
6
+ #
7
+ # ## Conditions
8
+ #
9
+ # {#map}, {#custom_map} and {#custom_map_back} take conditions that skip the field
10
+ # when they don't hold:
11
+ #
12
+ # - `:if` / `:unless` - checked on the mapping instance
13
+ # - `:if_src` / `:unless_src` - checked on the object being read from
14
+ # - `:if_dest` / `:unless_dest` - checked on the object being written to
15
+ #
16
+ # A condition is a method name, called on that object, or a proc, run with the
17
+ # object as `self` and passed as its argument (a lambda may take no arguments).
18
+ # For {#map}, the reverse mapping swaps the `_src` and `_dest` conditions so they
19
+ # still check the same object.
20
+ module ClassMethods
21
+ # @return [Hash{Symbol => Hash}] options for each field {Mapping#map} sets, by field name
22
+ def mappings
23
+ {}.freeze
24
+ end
25
+
26
+ # @return [Hash{Symbol => Hash}] options for each field {Mapping#map_back} sets, by field name
27
+ def map_back_mappings
28
+ {}.freeze
29
+ end
30
+
31
+ # Copies the `src` field to the `dest` field, and `dest` back to `src` in {Mapping#map_back}.
32
+ #
33
+ # @example
34
+ # map :email # email -> email
35
+ # map :email, :email_address # email -> email_address
36
+ # map :active, if_dest: :new_record?
37
+ #
38
+ # @param src [Symbol, String] field read from the source
39
+ # @param dest [Symbol, String] field written on the destination, defaults to `src`
40
+ # @param options [Hash] conditions (see {ClassMethods}); other keys are kept in {#mappings}
41
+ # @return [void]
42
+ def map(src, dest = nil, options = {})
43
+ if dest.is_a?(Hash)
44
+ options = dest
45
+ dest = nil
46
+ end
47
+
48
+ dest ||= src
49
+
50
+ options = ::Mappable::Mapping.default_mapping_options(src, dest).merge(options)
51
+ add_value_to_class_method(:mappings, dest.to_sym => options)
52
+ add_value_to_class_method(:map_back_mappings, src.to_sym => ::Mappable::Mapping.map_back_options(options))
53
+ compile_mappings
54
+ end
55
+
56
+ # Sets the `dest` field from a method of the mapping, or a block, given the source.
57
+ # Only applies to {Mapping#map}; use {#custom_map_back} for the reverse.
58
+ #
59
+ # @example
60
+ # custom_map :name # calls the mapping's name(src) method
61
+ # custom_map :name, :full_name # calls full_name(src)
62
+ # custom_map(:name) { |src| "#{src.first_name} #{src.last_name}" }
63
+ #
64
+ # @param dest [Symbol, String] field written on the destination
65
+ # @param custom_method [Symbol, String, Proc] defaults to the block, then to `dest`
66
+ # @param options [Hash] conditions (see {ClassMethods}); other keys are kept in {#mappings}
67
+ # @return [void]
68
+ def custom_map(dest, custom_method = nil, options = {}, &)
69
+ add_custom_mapping(:mappings, dest, custom_method, options, &)
70
+ end
71
+
72
+ # Sets the `dest` field on the source object from a method of the mapping, or a block,
73
+ # given the destination object. Used by {Mapping#map_back}.
74
+ #
75
+ # @example
76
+ # custom_map_back(:first_name) { |contact| contact.name.split(' ', 2).first }
77
+ #
78
+ # @param dest [Symbol, String] field written on the source object
79
+ # @param custom_method [Symbol, String, Proc] defaults to the block, then to `dest`
80
+ # @param options [Hash] conditions (see {ClassMethods}); other keys are kept in {#map_back_mappings}
81
+ # @return [void]
82
+ def custom_map_back(dest, custom_method = nil, options = {}, &)
83
+ add_custom_mapping(:map_back_mappings, dest, custom_method, options, &)
84
+ end
85
+
86
+ # Regenerates {Mapping#map} and {Mapping#map_back} from the current mappings.
87
+ # @api private
88
+ # @return [void]
89
+ def compile_mappings
90
+ compiler = ::Mappable::Compiler.new
91
+ source = [
92
+ compiler.method_source(:map, mappings),
93
+ compiler.method_source(:map_back, map_back_mappings)
94
+ ].join("\n")
95
+
96
+ store_procs(compiler.procs)
97
+ %i[map map_back].each { |name| remove_method(name) if method_defined?(name, false) }
98
+ class_eval(source, __FILE__, __LINE__)
99
+ end
100
+
101
+ private
102
+
103
+ def add_custom_mapping(method, dest, custom_method, options, &block)
104
+ if custom_method.is_a?(Hash)
105
+ options = custom_method
106
+ custom_method = nil
107
+ end
108
+
109
+ custom_method ||= block || dest
110
+
111
+ options = ::Mappable::Mapping.default_custom_mapping_options(dest, custom_method).merge(options)
112
+ add_value_to_class_method(method, dest.to_sym => options)
113
+ compile_mappings
114
+ end
115
+
116
+ def store_procs(procs)
117
+ name = ::Mappable::Compiler::PROCS_CONSTANT
118
+ remove_const(name) if const_defined?(name, false)
119
+ const_set(name, procs.freeze)
120
+ private_constant(name)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -1,79 +1,113 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mappable
4
- # Defines what fields to map
4
+ # Declares which fields are copied from one object to another, and back.
5
+ #
6
+ # Mapping classes are usually created by {Mappable::ClassMethods#map_to}, but any
7
+ # class can include this module and declare mappings with {ClassMethods#map},
8
+ # {ClassMethods#custom_map} and {ClassMethods#custom_map_back}. Each declaration
9
+ # regenerates the class's {#map} and {#map_back} methods (see {Compiler}).
10
+ #
11
+ # @example
12
+ # class ContactMapping
13
+ # include Mappable::Mapping
14
+ #
15
+ # map :email, :email_address
16
+ # custom_map(:name) { |user| "#{user.first_name} #{user.last_name}" }
17
+ # custom_map_back(:first_name) { |contact| contact.name.split(' ', 2).first }
18
+ # custom_map_back(:last_name) { |contact| contact.name.split(' ', 2).last }
19
+ # end
20
+ #
21
+ # ContactMapping.new.map(user, Contact.new) # => the contact
22
+ # ContactMapping.new.map_back(contact, User.new) # => the user
5
23
  module Mapping
24
+ autoload :ClassMethods, 'mappable/mapping/class_methods'
25
+
26
+ # @api private
6
27
  def self.included(base)
7
28
  base.extend InheritanceHelper::Methods
8
29
  base.extend ClassMethods
9
30
  end
10
31
 
32
+ # Options for copying the `src` field into the `dest` field
33
+ # @api private
11
34
  def self.default_mapping_options(src, dest)
12
35
  {
13
36
  src: src.to_sym,
14
- src_getter: src.to_s.freeze,
15
- src_setter: "#{src}=",
37
+ getter: src.to_s.freeze,
16
38
  dest: dest.to_sym,
17
- dest_getter: dest.to_s.freeze,
18
- dest_setter: "#{dest}="
39
+ setter: "#{dest}="
19
40
  }
20
41
  end
21
42
 
22
- # no-doc
23
- module ClassMethods
24
- def mappings
25
- {}.freeze
26
- end
27
-
28
- def map(src, dest = nil, options = {})
29
- if dest.is_a?(Hash)
30
- options = dest
31
- dest = nil
32
- end
33
-
34
- dest ||= src
35
-
36
- options = ::Mappable::Mapping.default_mapping_options(src, dest)
37
- .merge(options)
38
-
39
- add_value_to_class_method(:mappings, src.to_sym => options)
40
- end
43
+ # Options for setting the `dest` field from a custom method or proc
44
+ # @api private
45
+ def self.default_custom_mapping_options(dest, custom_method)
46
+ {
47
+ map_method: custom_method,
48
+ dest: dest.to_sym,
49
+ setter: "#{dest}="
50
+ }
41
51
  end
42
52
 
43
- def map(src_model, dest_model)
44
- self.class.mappings.each do |_, options|
45
- next if skip?(src_model, dest_model, options)
53
+ # Each condition of a {ClassMethods#map} call and the condition it becomes in the
54
+ # reverse mapping. The source and destination swap places, so _src and _dest
55
+ # conditions swap too and still check the same object.
56
+ # @api private
57
+ MAP_BACK_CONDITIONS = {
58
+ if: :if,
59
+ unless: :unless,
60
+ if_src: :if_dest,
61
+ unless_src: :unless_dest,
62
+ if_dest: :if_src,
63
+ unless_dest: :unless_src
64
+ }.freeze
46
65
 
47
- dest_model.public_send(options[:dest_setter], src_model.public_send(options[:src_getter]))
66
+ # Reverses the options of a {ClassMethods#map} call (see {MAP_BACK_CONDITIONS})
67
+ # @api private
68
+ def self.map_back_options(options)
69
+ new_options = default_mapping_options(options[:dest], options[:src])
70
+ MAP_BACK_CONDITIONS.each do |cond, map_back_cond|
71
+ new_options[map_back_cond] = options[cond] if options[cond]
48
72
  end
49
- dest_model
73
+ new_options
50
74
  end
51
75
 
52
- def skip?(src_model, dest_model, options)
53
- return true if options[:if] && !call_method(dest_model, options[:if])
54
- return true if options[:unless] && call_method(dest_model, options[:unless])
55
-
56
- false
76
+ # Creates a mapping class and sets it as a constant of `base_module`.
77
+ #
78
+ # @param base_module [Module] where the class's constant is set
79
+ # @param name [String, Symbol] the class is named after it: `:contact` becomes `ContactMapping`
80
+ # @param options [Hash]
81
+ # @option options [String] :class_name the class's name instead of one built from `name`
82
+ # @option options [Class] :base_class superclass, so a mapping can extend another one
83
+ # @yield evaluated in the class, to declare its mappings
84
+ # @return [Class]
85
+ def self.create(base_module, name, options = {}, &block)
86
+ class_name = options[:class_name] || "#{::Mappable::Utils.classify_name(name)}Mapping"
87
+ kls = base_module.const_set(class_name, Class.new(options[:base_class] || Object))
88
+ kls.include(::Mappable::Mapping)
89
+ kls.class_eval(&block) if block
90
+ kls
57
91
  end
58
92
 
59
- def call_method(model, method)
60
- case method
61
- when Symbol
62
- model.public_send(method)
63
- when Proc
64
- model.instance_eval(&method)
65
- else
66
- raise("wrong type, failed to call method #{method}")
67
- end
93
+ # Copies the mapped fields from `src_model` to `dest_model`. Replaced by a generated
94
+ # method once the class declares a mapping.
95
+ #
96
+ # @param _src_model [Object] not read: there is nothing to copy
97
+ # @param dest_model [Object]
98
+ # @return [Object] dest_model
99
+ def map(_src_model, dest_model)
100
+ dest_model
68
101
  end
69
102
 
70
- def self.create(base_module, name, options = {}, &block)
71
- options[:class_name] ||= ::Mappable::Utils.classify_name(name.to_s) + 'Mapping'
72
- kls = Class.new(options[:base_class] || Object)
73
- kls = base_module.const_set(options[:class_name], kls)
74
- kls.send(:include, ::Mappable::Mapping)
75
- kls.class_eval(&block) if block
76
- kls
103
+ # Copies the fields of `dest_model` back to `src_model`, reversing {#map}. Replaced by a
104
+ # generated method once the class declares a mapping.
105
+ #
106
+ # @param _dest_model [Object] the object to read from, not read: there is nothing to copy
107
+ # @param src_model [Object] the object to write to
108
+ # @return [Object] src_model
109
+ def map_back(_dest_model, src_model)
110
+ src_model
77
111
  end
78
112
  end
79
113
  end
@@ -1,12 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mappable
4
- # General purpouse utility methods
4
+ # General purpose utility methods
5
5
  module Utils
6
6
  module_function
7
7
 
8
+ # Converts a mapping name into a class name: drops anything that isn't a letter,
9
+ # digit, underscore or dash, then camel cases the words split by underscores and dashes.
10
+ #
11
+ # @example
12
+ # Mappable::Utils.classify_name('user_profile') # => "UserProfile"
13
+ # Mappable::Utils.classify_name('UserProfile') # => "UserProfile"
14
+ #
15
+ # @param name [String, Symbol]
16
+ # @return [String]
8
17
  def classify_name(name)
9
- name.gsub(/[^\da-z_-]/, '').gsub(/(^.|[_|-].)/) { |m| m[-1].upcase }
18
+ name.to_s.gsub(/[^\da-zA-Z_-]/, '').gsub(/(?:\A|[_-]+)([\da-zA-Z])/) { ::Regexp.last_match(1).upcase }
10
19
  end
11
20
  end
12
21
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mappable
4
+ # The gem's version, bumped by release-please
5
+ VERSION = '0.2.0'
6
+ end
data/lib/mappable.rb CHANGED
@@ -2,34 +2,19 @@
2
2
 
3
3
  require 'inheritance-helper'
4
4
 
5
- # Transfer/Map data from one model to the next
5
+ # Maps data from one object to another and back.
6
+ #
7
+ # Include it in a class and declare mappings with {ClassMethods#map_to}.
6
8
  module Mappable
9
+ autoload :ClassMethods, 'mappable/class_methods'
10
+ autoload :Compiler, 'mappable/compiler'
7
11
  autoload :Mapping, 'mappable/mapping'
8
12
  autoload :Utils, 'mappable/utils'
13
+ autoload :VERSION, 'mappable/version'
9
14
 
15
+ # @api private
10
16
  def self.included(base)
11
17
  base.extend InheritanceHelper::Methods
12
18
  base.extend ClassMethods
13
19
  end
14
-
15
- # no-doc
16
- module ClassMethods
17
- def maps
18
- {}.freeze
19
- end
20
-
21
- def map_to(name, options = {}, &block)
22
- mapping = Mapping.create(self, name, options, &block)
23
- add_value_to_class_method(:maps, name => mapping)
24
-
25
- class_eval(
26
- <<-STR, __FILE__, __LINE__ + 1
27
- def map_to_#{name}(dest)
28
- ::#{mapping.name}.new.map(self, dest)
29
- dest
30
- end
31
- STR
32
- )
33
- end
34
- end
35
20
  end
metadata CHANGED
@@ -1,55 +1,59 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: model-mapper
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Youch
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2019-09-04 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: inheritance-helper
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
- - - ">="
16
+ - - "~>"
18
17
  - !ruby/object:Gem::Version
19
- version: '0'
18
+ version: '0.2'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
- - - ">="
23
+ - - "~>"
25
24
  - !ruby/object:Gem::Version
26
- version: '0'
27
- description: Easy way to configure what data is mapped between models
25
+ version: '0.2'
26
+ description: 'Declare once how fields map from one object to another, and Mappable
27
+ compiles it into plain Ruby methods that copy the data both ways at close to hand-written
28
+ speed. Rename fields, combine several into one (first_name + last_name -> name)
29
+ and split them back out, and skip fields with conditions on the source, the destination
30
+ or the mapping itself. Works with any objects that have getters and setters: Structs,
31
+ ActiveRecord models, plain Ruby classes.'
28
32
  email: dougyouch@gmail.com
29
33
  executables: []
30
34
  extensions: []
31
35
  extra_rdoc_files: []
32
36
  files:
33
- - ".gitignore"
34
- - ".rubocop.yml"
35
- - ".ruby-gemset"
36
- - ".ruby-version"
37
- - ".travis.yml"
38
- - Gemfile
39
- - Gemfile.lock
37
+ - CHANGELOG.md
40
38
  - LICENSE
41
39
  - README.md
42
- - gemfiles/travis.gemfile
43
40
  - lib/mappable.rb
41
+ - lib/mappable/class_methods.rb
42
+ - lib/mappable/compiler.rb
44
43
  - lib/mappable/mapping.rb
44
+ - lib/mappable/mapping/class_methods.rb
45
45
  - lib/mappable/utils.rb
46
+ - lib/mappable/version.rb
46
47
  - lib/model-mapper.rb
47
- - model-mapper.gemspec
48
48
  homepage: https://github.com/dougyouch/mappable
49
49
  licenses:
50
50
  - MIT
51
- metadata: {}
52
- post_install_message:
51
+ metadata:
52
+ rubygems_mfa_required: 'true'
53
+ source_code_uri: https://github.com/dougyouch/mappable
54
+ changelog_uri: https://github.com/dougyouch/mappable/blob/master/CHANGELOG.md
55
+ bug_tracker_uri: https://github.com/dougyouch/mappable/issues
56
+ documentation_uri: https://rubydoc.info/gems/model-mapper
53
57
  rdoc_options: []
54
58
  require_paths:
55
59
  - lib
@@ -57,16 +61,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
57
61
  requirements:
58
62
  - - ">="
59
63
  - !ruby/object:Gem::Version
60
- version: '0'
64
+ version: '3.2'
61
65
  required_rubygems_version: !ruby/object:Gem::Requirement
62
66
  requirements:
63
67
  - - ">="
64
68
  - !ruby/object:Gem::Version
65
69
  version: '0'
66
70
  requirements: []
67
- rubyforge_project:
68
- rubygems_version: 2.7.10
69
- signing_key:
71
+ rubygems_version: 4.0.20
70
72
  specification_version: 4
71
- summary: Map data between models
73
+ summary: Fast, declarative two-way mapping between Ruby objects
72
74
  test_files: []
data/.gitignore DELETED
@@ -1,51 +0,0 @@
1
- # rcov generated
2
- coverage
3
- coverage.data
4
-
5
- # rdoc generated
6
- rdoc
7
-
8
- # yard generated
9
- doc
10
- .yardoc
11
-
12
- # bundler
13
- .bundle
14
-
15
- # jeweler generated
16
- pkg
17
-
18
- # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
19
- #
20
- # * Create a file at ~/.gitignore
21
- # * Include files you want ignored
22
- # * Run: git config --global core.excludesfile ~/.gitignore
23
- #
24
- # After doing this, these files will be ignored in all your git projects,
25
- # saving you from having to 'pollute' every project you touch with them
26
- #
27
- # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line)
28
- #
29
- # For MacOS:
30
- #
31
- .DS_Store
32
-
33
- # For TextMate
34
- #*.tmproj
35
- #tmtags
36
-
37
- # For emacs:
38
- *~
39
- \#*
40
- .\#*
41
-
42
- # For vim:
43
- *.swp
44
-
45
- # For redcar:
46
- #.redcar
47
-
48
- # For rubinius:
49
- #*.rbc
50
-
51
- *.gem
data/.rubocop.yml DELETED
@@ -1,12 +0,0 @@
1
- Naming/FileName:
2
- Enabled: false
3
- Metrics/LineLength:
4
- Max: 120
5
- Metrics/ModuleLength:
6
- Max: 120
7
- Metrics/MethodLength:
8
- Max: 20
9
- AllCops:
10
- Exclude:
11
- - 'spec/spec_helper.rb'
12
- - 'spec/**/*_spec.rb'
data/.ruby-gemset DELETED
@@ -1 +0,0 @@
1
- mappable
data/.ruby-version DELETED
@@ -1 +0,0 @@
1
- 2.6.3
data/.travis.yml DELETED
@@ -1,17 +0,0 @@
1
- env:
2
- global:
3
- - CC_TEST_REPORTER_ID=f54f3242f54a3040953c9127294fef862cb1851d12333d004619a8b7b7842054
4
- rvm:
5
- - 2.6.3
6
- - 2.1.9
7
- - 1.9.3
8
- gemfile: gemfiles/travis.gemfile
9
- language: ruby
10
- before_script:
11
- - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
12
- - chmod +x ./cc-test-reporter
13
- - ./cc-test-reporter before-build
14
- script:
15
- - bundle exec rspec
16
- after_script:
17
- - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT
data/Gemfile DELETED
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- source 'http://rubygems.org'
4
-
5
- gem 'inheritance-helper'
6
-
7
- group :development do
8
- gem 'rake'
9
- gem 'rubocop'
10
- end
11
-
12
- group :spec do
13
- gem 'rspec'
14
- gem 'simplecov'
15
- end
data/Gemfile.lock DELETED
@@ -1,54 +0,0 @@
1
- GEM
2
- remote: http://rubygems.org/
3
- specs:
4
- ast (2.4.0)
5
- diff-lcs (1.3)
6
- docile (1.3.2)
7
- inheritance-helper (0.1.0)
8
- jaro_winkler (1.5.3)
9
- json (2.2.0)
10
- parallel (1.17.0)
11
- parser (2.6.4.0)
12
- ast (~> 2.4.0)
13
- rainbow (3.0.0)
14
- rake (12.3.3)
15
- rspec (3.8.0)
16
- rspec-core (~> 3.8.0)
17
- rspec-expectations (~> 3.8.0)
18
- rspec-mocks (~> 3.8.0)
19
- rspec-core (3.8.2)
20
- rspec-support (~> 3.8.0)
21
- rspec-expectations (3.8.4)
22
- diff-lcs (>= 1.2.0, < 2.0)
23
- rspec-support (~> 3.8.0)
24
- rspec-mocks (3.8.1)
25
- diff-lcs (>= 1.2.0, < 2.0)
26
- rspec-support (~> 3.8.0)
27
- rspec-support (3.8.2)
28
- rubocop (0.74.0)
29
- jaro_winkler (~> 1.5.1)
30
- parallel (~> 1.10)
31
- parser (>= 2.6)
32
- rainbow (>= 2.2.2, < 4.0)
33
- ruby-progressbar (~> 1.7)
34
- unicode-display_width (>= 1.4.0, < 1.7)
35
- ruby-progressbar (1.10.1)
36
- simplecov (0.17.0)
37
- docile (~> 1.1)
38
- json (>= 1.8, < 3)
39
- simplecov-html (~> 0.10.0)
40
- simplecov-html (0.10.2)
41
- unicode-display_width (1.6.0)
42
-
43
- PLATFORMS
44
- ruby
45
-
46
- DEPENDENCIES
47
- inheritance-helper
48
- rake
49
- rspec
50
- rubocop
51
- simplecov
52
-
53
- BUNDLED WITH
54
- 1.17.3
@@ -1 +0,0 @@
1
- gemfiles/../Gemfile
data/model-mapper.gemspec DELETED
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Gem::Specification.new do |s|
4
- s.name = 'model-mapper'
5
- s.version = '0.1.0'
6
- s.licenses = ['MIT']
7
- s.summary = 'Map data between models'
8
- s.description = 'Easy way to configure what data is mapped between models'
9
- s.authors = ['Doug Youch']
10
- s.email = 'dougyouch@gmail.com'
11
- s.homepage = 'https://github.com/dougyouch/mappable'
12
- s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|script)/}) }
13
-
14
- s.add_runtime_dependency 'inheritance-helper'
15
- end