A declarative DSL for mapping Hash/JSON parameters to Ruby objects with type validation, referential integrity checks, combined record validation, and opt-in persistence. Designed for service objects that receive external input and need to validate, transform, and assign it to a model.
# Gemfile
gem 'model_mapper', git: 'https://github.com/moduloTech/model_mapper.git'When used in a Rails application, the Railtie is auto-loaded and registers the gem's I18n locale files automatically.
class UpdateWidgetService
include ModelMapper
# No initialize, no from/to needed: ModelMapper provides a standard initializer and `from`/`to`
# default to the mapped `record` / `params`.
map_model do
record_alias :widget # optional: expose #widget as an alias of #record
attribute :name do
required true
end
attribute :status do
from :info, :status # dig into nested hash
type :enumerated
allowing %w[active inactive archived]
end
end
end
widget = Widget.find(1)
service = UpdateWidgetService.new(widget, params, user: current_user) # extra kwargs → readers
service.map_to_model! # validate (mapper + record) + assign; raises on invalid, never persists
service.widget.save! # persistence stays in the caller's hands (or use save_to_model!)include ModelMapper provides a standard initializer — Mapper.new(record, params, **context):
record→ the mapped object (#record), default target ofto.params→ the source hash (#params), default source offrom.- every extra keyword becomes an ivar + reader (e.g.
user:⇒#user), so mappers can carry context (scoping, the current user, …) without a custom initializer.
A class may still define its own initialize (and call super). record_alias :name exposes an
alias of #record (e.g. #widget, #mission) for readability.
The map_model block configures the mapping. It is evaluated at the class level.
Optional — they default to :@params and :@record (the standard initializer's params /
record). Declare them only to read/write elsewhere.
map_model do
from :@params # instance variable (default)
from :params # method call
to :widget # method call
to :@widget # instance variable
endBoth accept a Symbol. Symbols starting with @ are resolved as instance variables; others are sent as method calls on the service instance.
Payload keys are read indifferently to symbol and string at every level — attribute :name reads
{ name: … } and { 'name' => … } alike, as an ActionController::Parameters already does. When a
plain Hash carries both forms, the key as declared wins.
A single association declaration covers every way to link a record (or records) to the target:
1‑1 or 1‑N, by reference (an existing record, by id, scoped) and/or by construction
(a nested record built via a sub-mapper). It supersedes the older type :referential,
type :association, and type :array (carrying records) — see Deprecations below.
map_model do
# 1‑1 reference — link an existing, scoped record; the OBJECT is assigned (not the id).
association :call_origin do
allowing -> { CallOrigin.where(company: user.companies) }
required true
end
# 1‑1 build — nested record built by a sub-mapper (via accepts_nested_attributes_for).
association :vehicle_attributes do
from :vehicle
with VehicleMapper, -> { { company: call_origin.company } }
end
# 1‑N build.
association :missions_attributes, many: true do
from :missions
with MissionMapper, -> { { company: call_origin.company } }
end
# 1‑N reference — link existing, scoped records; the OBJECTS are assigned.
association :tags, many: true do
allowing -> { current_company.tags }
end
# Upsert — an id ∈ `allowing` updates that record in place; no id builds a new one.
association :missions_attributes, many: true do
from :missions
with MissionMapper, -> { { company: call_origin.company } }
allowing -> { call_origin.company.missions }
end
endTwo axes, both explicit.
- Destination is the 1st argument — never inferred. It is the setter actually called on the
target. In reference mode you name the association (
:call_origin⇒call_origin=, the object); in build mode you name the nested-attributes writer (:vehicle_attributes⇒vehicle_attributes=, so the model still needsaccepts_nested_attributes_for :vehicle). - Cardinality — 1‑1 by default,
many: truefor 1‑N. (Not inferred from ActiveRecord.) - Resolution —
allowing(reference) and/orwith(build); composing both gives upsert.
Resolution, per value (or per element when many: true):
allowing |
with |
id in payload | behaviour |
|---|---|---|---|
| — | — | — | value absent → skipped (or required → error) |
| yes | no | yes | link — id validated ∈ allowing; the OBJECT is assigned |
| yes | no | no | skipped (or required → error) — an id is required to link |
| no | yes | (ignored) | build (create) via the sub-mapper — any id in the payload is ignored |
| yes | yes | yes | update — id validated ∈ allowing; that record is attached and updated in place |
| yes | yes | no | build (create) via the sub-mapper |
Update is scoped only. In-place update happens exclusively under upsert (
allowingandwith) and only for an id inside the scope.withalone always creates — it never updates by id — so there is no unscoped update path. The attach is in-memory; persistence is deferred to the parent's own save, which cascades to the child throughaccepts_nested_attributes_for(declare it on the parent).The attached record is also linked: a
belongs_togets the parent's foreign key, a 1‑N element gets the parent's key (and theas:type column) — set in memory, written by the parent's save, whether or not ActiveRecord detects an inverse for the association. Ahas_many :throughor HABTM element is updated in place but not linked: its link is a join row, which the upsert does not create — add it through the join model.Association scope: an association narrowed by a
wherescope (has_many :spare_parts, -> { where(name: 'spare') }, or a scopedbelongs_to/has_one) only ever holds records that match it. A recordallowingaccepts but that scope excludes is refused on its id path (e.g.spare_parts.0.id) rather than linked outside the association. The scope is weighed on the record as stored, with one read, before the payload is mapped onto it.Ids are taken as sent: on an integer key an id must be an Integer or a string of digits —
1,"1"and"01"are, whiletrue,"1abc",1.9," 1 "or"1.0", whichfind_bywould read as record 1, are refused on the id path. Any other key type (a uuid, a string) is left tofind_byand its column's own cast. This holds in reference and upsert mode and for a legacytype :referential; a blank element of amultiplelist ("") is ignored, aswhereignores it.1-1 replace: mapping never writes to the DB (associations are staged in memory before overall validity is known), so a 1-1 upsert cannot disassociate a
has_one's current child (that needs an immediate, un-rollback-able write). It therefore rejects an in-scope id that differs from the parent's current child with a params-path error (e.g.manual.id) instead of silently orphaning the old child on save. Updating the same child, or attaching when the parent has none, works as usual; perform a genuinehas_onereplacement in the caller. Abelongs_tois replaced as usual — only the parent's foreign key moves, the previous record is left untouched. 1-N is unaffected.
-
References assign the loaded object(s), not an id — the record fetched for validation is the one assigned (one fewer query, no re-load). A bare id (
{ call_origin: 5 }) is accepted as well as{ call_origin: { id: 5 } }. -
with SubMapper, -> { context }— the sub-mapper class and an optional lambda (evaluated in the parent) building its keyword context. Sub-mapper errors merge under path-prefixed keys (vehicle.immat,missions.0.driver). An absent payload section is not built; a present-but-empty{}is built and validated; an empty array yields zero records. -
Optional by default — an absent section is fine; add
required trueto demand it. Out-of-scope or unknown ids are rejected (never silently dropped), keyed on the params path (call_origin.id,missions.2.id). -
A reference that ActiveRecord writes at once —
immediate_write. A reference is assigned through the association writer (parts=,manual=). On a persisted record, for ahas_one,has_many(:throughincluded) orhas_and_belongs_to_many, ActiveRecord applies that change to the database immediately, during the map: new children get their foreign key (or join row), old ones are nullified, or deleted underdependent: :destroy/:delete_all/:delete(join rows are deleted) — and it stays so even if the mapping then fails validation.immediate_writedecides what happens then:the assignment writes now it writes nothing undeclared assigned (written) + a one-time deprecation warning assigned immediate_write trueassigned (written) assigned immediate_write falserefused: InvalidValueErroron the param's path (parts,widget.parts), nothing assigned, nothing writtenassigned The assignment writes nothing on a new (unsaved) record, for a
belongs_to, and when the value equals the current one, compared as ActiveRecord does (same child; same records, in any order — an unsaved child already in the association does not count; a repeated record is no change on ahas_manybut adds a join row on a:throughor HABTM, so it counts there). A key that is absent, or a null that is skipped, assigns nothing and is never refused — unless adefaultfills it: a default is assigned like a sent value, and is refused like one.The same holds for an
attributeassigned through a collection's ids writer —part_idsfor ahas_many :parts, whatever itstype(of :integer, untyped, or the deprecatedof :referential): ActiveRecord'spart_ids=replaces the collection at once just likeparts=. Declareimmediate_writeon it the same way. The ids that reach the writer are compared as ActiveRecord's ids writer reads them (blanks dropped, cast to the key type, column by column for a composite key; anof :integerattribute already refuses a blank element itself), and an*_idsattribute that is not a collection's ids writer on the record (a plain accessor) is left alone.The check runs at assignment, after
before_assignation, on the params as the hook left them: a value the hook replaces or adds for a guarded param is checked like a sent one, and one it removes is not. A key the hook adds for a param that is not declared, or declaredassign false, is assigned as is. An id that names no record is not a write: the ids writer raisesRecordNotFoundbefore replacing anything, and that error is left to surface.immediate_writeon a param declaredassign falseraisesConfigurationError: it is never assigned, so never written. A future release refuses by default; see Deprecations. Replace such children in the caller, after the map succeeded, when the write must wait for validation.Under
immediate_write false, a value that would be refused if it reached the writer as sent reads asnilto the params declared after it (theirmap_if,authorize,allowing,required,default), as a param that failed validation does — a rule that trips over thatnilis reported as an error on its own param — so nothing they decide rests on it;before_assignationand everything after the loop read it as usual. Two consequences are accepted: the forecast is made beforebefore_assignation, so a value the hook then puts back (nothing refused in the end) was stillnilto those params; and while a value is withheld, any exception a later rule raises — even one unrelated to it — is reported as an error on that rule's param instead of propagating. -
Error reporting differs by mode for 1‑N (worth knowing): a 1‑N reference fails fast — the first out-of-scope element raises and any already-resolved elements are discarded, so only one
name.N.idis reported. A 1‑N upsert collects every out-of-scope id and reports them all. Both still reject out-of-scope ids; they differ only in how many are surfaced at once.
For an array of scalars, type :array needs an of element strategy (arrays of records or
referenced ids now use association …, many: true). Declaring type :array with neither of nor
with, with both, or of outside an array raises ModelMapper::ConfigurationError on the first map.
of names the element type, validated/coerced per element through the same machinery as the scalar
types: :string, :integer, :float, :date, :boolean, :enumerated, :custom, or :any
(accept elements unchanged). Validation fails fast on the first bad element, with an indexed field
path (e.g. numbers.2).
attribute :numbers do
type :array
of :integer # ["1","2"] => [1, 2]; a non-integer element => "numbers.<i>" error
end
attribute :statuses do
type :array
of :enumerated
allowing %w[open closed] # the attribute's `allowing` is applied to each element
end- The attribute's
allowingapplies to every element (so:enumeratedelements share one scope). - An empty array is treated as blank (like any empty value): dropped when optional, an error when
required.
Use
association, nottype :array, for arrays of records or referenced ids.type :arraywithwith(array of records) andof :referential(array of referenced ids) are deprecated in favour ofassociation …, many: true(see Associations above and Deprecations below).type :arraywith a scalarof(:string,:integer, …) stays.
Declares a parameter to validate and map. Without a block, it reads source[name] and assigns it directly to the target.
attribute :name # simple pass-through
attribute :price do # with options
type :float
required true
end(To link/build records, use association — see above.)
Deprecation:
atis the former name offromand still works, but emits a deprecation warning. Preferfrom.
from, id_field, allowing, with, required, assign, default, default_on_invalid, of,
type, multiple, nullable, immediate_write are block methods; many: and the cardinality belong
to association (nullable: and immediate_write: are also accepted as keywords). map_if and
authorize are block methods (see below); authorize: is also accepted as a keyword.
| Option | Type | Default | Description |
|---|---|---|---|
from |
*keys |
[name] |
Key path for Hash#dig into the source params (the section, for an association) — was at |
id_field |
Symbol | :id |
Reference/upsert: the key read inside the from section and the find_by column — replaces field |
allowing |
various | nil |
Allowed values/records — Array, AR scope, or lambda. Reference mode of association; also :enumerated/:custom |
with |
class, lambda | nil |
Sub-mapper (+ optional context lambda) for an association built/upserted via accepts_nested_attributes_for |
many: |
Bool | false |
association cardinality — true ⇒ 1‑N (kwarg on association) |
map_if |
Proc | nil |
Block method controlling whether the attribute/association is processed — replaces condition |
authorize |
Proc / Bool | nil |
May the caller write this field? A falsy result refuses it with an UnauthorizedFieldError |
required |
Bool / Proc | false |
Whether nil/blank raises an error |
nullable |
Bool | undeclared | What a clearing request (null, or [] on a multi-valued param) does: true clears, false refuses — see below |
immediate_write |
Bool | undeclared | Reference association, or an attribute named <collection>_ids: whether a change ActiveRecord writes to the DB during the map is accepted (true) or refused (false) — see below |
assign |
Bool | true |
Include in assign_attributes; false = validate-only (was save, which now raises) |
default |
value / Proc | nil |
Fallback when the source value is nil/missing; authorize: false exempts it from authorize |
default_on_invalid |
Bool | false |
Use default when validation fails instead of raising |
type |
Symbol | nil |
Scalar validation type (:integer, :float, :date, :boolean, :enumerated, :custom) or :array |
of |
Symbol | nil |
Element type for a type :array of scalars |
multiple |
Bool | false |
:enumerated only — accept an array of values |
field |
Symbol | :id |
Deprecated — use id_field |
identifier |
Symbol | :id |
Deprecated (its 0.4.1 name) — use id_field |
condition |
Proc | nil |
Deprecated — use map_if |
Accepts true, false, or a lambda:
required true
required ->(params) { params[:code].nil? }
required ->(params, target) { target.new_record? }The lambda is called (not instance_execed: self is where it was written) with as many of
(params, target) as its signature accepts — an optional argument counts, so
->(params, target = nil) gets both.
An absent key never touches the current value. A key sent with a clearing request is governed
by nullable: an explicit null (present, raw value nil), or an empty list on a
multi-valued param (many: true, type :array) — what a client sends once it has removed every
element (JSON.stringify({ parts: [] })).
nullable |
Clearing request (null, or [] on a multi-valued param) |
|---|---|
| undeclared | treated like an absent key (skipped, or replaced by its default) — deprecated, emits a warning |
true |
clears the value: nil, or [] for a collection (many: true, type :array); default, allowing and type validation do not apply |
false |
refused with an InvalidNilValueError on the param's path; the key itself stays optional |
attribute :nickname, nullable: true # { nickname: null } clears it
attribute :vin_mandatory, nullable: false # NOT NULL column: a null is an error, absence is fine
attribute :tag_ids, nullable: true do # { tag_ids: [] } and { tag_ids: null } both empty it
type :array
of :integer
end
association :category do
allowing -> { Category.enabled }
nullable true # { category: null } unlinks the category
endnullabletakestrueorfalse(nilleaves it undeclared); anything else raisesConfigurationError.requiredwins: a required param refuses a null or an empty list whatevernullablesays.""and{}are not clearing requests and keep being skipped, and[]is one only on a multi-valued param — on a scalar it names nothing to clear, and says nothing whatevernullablesays. On a nestedfrom :info, :status,{ info: null }or{ info: {} }is not a null forstatuseither.- The key is read like any value, indifferently to symbol/string:
{ 'nickname' => null }is a null forattribute :nickname. - The null must sit on the last key of the
frompath. The deprecatedtype :referentialreadsfrom :category, :id, so{ category: { id: null } }is its null and{ category: null }is not; anassociation :categoryreads the section itself, so{ category: null }is. - A param mapped by a sub-mapper (
association … with) is never cleared by a null or an empty list; declaringnullableon one raisesConfigurationError. ⚠️ On a persisted record, clearing ahas_one/has_manyreference goes through the association writer, which ActiveRecord applies to the database immediately, even if the mapping then fails validation: the previous children are nullified, or deleted underdependent: :destroy.immediate_writegoverns it like any other value of such a reference:immediate_write falserefuses the clear,immediate_write trueaccepts it (see Associations).
Accepts a static value or a lambda:
default 'active'
default -> { Time.current }
default ->(params) { params[:fallback_name] }A default or allowing lambda is instance_execed on the mapper and handed the source params when
its signature accepts an argument — required or optional: ->(params = nil) gets them too. (The
allowing of a type :custom attribute is a validator: its list is (value, source_params), see
:custom.)
Controls whether the attribute/association is processed at all. The lambda is executed via
instance_exec on the service instance, so it can access instance variables. (Named map_if because
if is a Ruby keyword and cannot be a bareword DSL method.)
attribute :status do
map_if -> { @include_status }
end
# With arguments:
map_if ->(target) { target.new_record? }
map_if ->(target, source_params) { source_params.key?(:status) }Deprecation:
conditionis the former name and still works, but emits a deprecation warning. Prefermap_if.
Per-field authorization: may this caller write this field? Same shape as map_if — a lambda
instance_execed on the mapper instance, so the record alias, the keyword context (user, …) and
the policies are all in scope — or a plain boolean. It is also accepted as a keyword
(attribute :name, authorize: …). Anything else (a Symbol, a String, a policy object) is refused at
class load: a value the mapper cannot call would read as always authorized on a field declared
guarded.
association :car_becoming do
allowing -> { job.company.car_becomings }
authorize -> { JobPolicy.new(Context::Company.new(job.company, user), job).update_car_becoming? }
endThe difference with map_if is what a falsy result does, and it is the whole point: map_if skips
the field in silence, so the caller gets a success for a write that never happened — which is
precisely what an authorization must never do. authorize instead records a
ModelMapper::UnauthorizedFieldError on the field, alongside every other error of the same call.
Four rules:
-
Weighed only when a value would land on the record, and handed THAT value. Three writes count:
- the payload carries a non-blank value — weighed on the raw value sent;
- a clearing request — an explicit
null, or[]on a multi-valued param — that clears the field (nullable true) — weighed on the cleared value; - a
defaultlands — whether the key was left out, sent asnullor blank, or sent invalid underdefault_on_invalid— weighed on the default, because it may itself read the payload and a rule inert on the field it sits next to is worse than no rule at all. Usedefault …, authorize: falsewhen the value is the mapper's own doing (see below).
Anything that writes nothing is not weighed: a field left out of a partial update, a blank value (
"",[]) or a clearing request withoutnullable trueand with no default behind it, adefaultthat resolves to nothing, an empty list sent to amany: truesub-mapper association. A form that posts every field — the untouched ones blank — does not block a caller on the fields it may not set. -
A sent value is weighed before validation. A refused field is never validated: the rule reads the value, nothing else does. A caller is not told what is wrong with a write it cannot make. A default or a clearing is weighed once validation has resolved it — it is only known then. A rule that raises is collected like any other lambda error when an earlier field already failed, on attributes and associations alike, not thrown out of
map_to_model. -
It also guards a validation-only field (
assign false). Nothing is written there by definition — what is refused is the right to submit the field, which is why the message says "cannot be set by this caller" rather than "cannot be modified". -
It applies to associations too, in reference mode and through a sub-mapper alike. A refused association builds nothing and runs no sub-mapper.
The refusal is keyed on the field itself —
"category","vehicle"— while every other error an association can produce is keyed on its identifier ("category.id"). This is deliberate: what is refused is the field, not the id, which the mapper has not even looked at yet. A caller that dispatches on the error key has to expect both.
# Rules may also depend on WHAT is being written: the widest signature receives the value.
authorize ->(target, source_params, value) { value.nil? || policy.may_set?(value) }A rule is called with as many of (target, source_params, value) as its signature accepts. A lambda
requiring more, or requiring a keyword argument, is refused at class load (a plain proc tolerates
missing arguments, so its extra parameters are left nil), as is authorize declared twice for the same param (as a
keyword and in the block — the keyword wins, so the block rule would vanish silently).
A default that supplies a value is a write, so it is weighed. When that value is the mapper's own
doing rather than the caller's, exempt it:
attribute :status do
default 'active', authorize: false # applied even to a caller refused on this field, absent or null alike
authorize -> { admin? } # governs what the caller sends
endWithout the exemption, a caller who may not set status is refused on it even though they never
sent it — which blocks their whole update.
A child mapper redeclares authorize like any other option; it can tighten or relax what its parent
declared, but it cannot remove the option.
Validates the value against a list of allowed values.
attribute :status do
type :enumerated
allowing %w[active inactive archived]
end
# With multiple values:
attribute :tags do
type :enumerated
allowing %w[urgent normal low]
multiple true
endLooks up an ActiveRecord record by the given value and assigns its id. Superseded by the
associationreference mode, which assigns the object and reads the section +id_field:# before attribute :zone_id do from :zone, :id type :referential allowing Zone.enabled end # after association :zone do from :zone allowing -> { Zone.enabled } endA custom lookup column was
field :name; it is nowid_field :name.Unchanged-value tolerance (legacy
:referentialonly): if the target already has the same value, the record is allowed even if it no longer matchesallowing(e.g. soft-deleted). The newassociationreference mode does not yet carry this tolerance.
Passes the value through a lambda for arbitrary validation/transformation. The lambda is executed via instance_exec on the mapper and receives as many of (value, source_params) as its signature accepts: ->(value) gets the value only, ->(value, params) both.
attribute :code do
type :custom
allowing ->(value, _params) { value.upcase }
endReturn the transformed value. Raise ModelMapper::InvalidValueError to reject.
Validates and converts to Float.
attribute :price do
type :float
endValidates and converts to Integer. Rejects non-integer strings (e.g. "12.5").
attribute :quantity do
type :integer
endParses via Time.zone.iso8601. Raises InvalidFormatError on failure.
attribute :scheduled_at do
type :date
endA string is true when, stripped and downcased, it is one of true, 1, yes, on, t
(ModelMapper::BOOLEAN_TRUE_VALUES); every other string — "false", "no", "f", "banana" — is
false. Any other value is cast by Ruby truthiness: false is false, everything else (including
0) is true.
As for every type, an empty value (nil, "", [], {}) never reaches the cast on an attribute:
it is treated as absent (nil, or InvalidNilValueError when required). Inside a type :array +
of :boolean, each element is cast as is, so nil and "" become false.
attribute :active do
type :boolean
endHooks are declared inside the map_model block and executed via instance_exec on the service instance.
1. before_validation(source_params, target_object)
2. -- attribute validation loop (mapper rules) --
-- if any mapper error: STOP here (target not assembled) --
3. before_assignation(source_params, validated_params)
4. -- assign_attributes --
5. -- record validation (target.valid?), merged into the combined errors --
6. before_save(source_params) # only with save_to_model / save_to_model!
7. -- persist (save/save!/on_save) -- # only with save_to_model / save_to_model!
8. after_save(source_params) # only with save_to_model / save_to_model!
map_model do
before_validation do |source_params, target_object|
# Runs before the validation loop. Modify source or target in-place.
end
before_assignation do |source_params, validated_params|
# Runs after validation, before assign_attributes.
# validated_params is a Hash you can mutate.
end
before_save do |source_params|
# Runs before persistence.
end
after_save do |source_params|
# Runs after persistence.
end
on_save do |target|
# Replaces the default target.save! call.
target.update_columns(...)
end
endFour methods. They all run combined validation first (mapper rules, then the
target's own valid? — see below). The map_* variants never persist; the
save_* variants persist explicitly (opt-in). The ! variants raise; the
non-bang variants collect errors instead.
map_to_model # validate + assign; non-raising → read #errors / #valid?
map_to_model! # validate + assign; raises ModelMapper::ValidationError if invalid
save_to_model # validate + assign + save (skipped when invalid); non-raising
save_to_model! # validate + assign + save! ; raises if invalid or on save failureAfter a non-bang call, inspect the result on the mapper instance:
service = UpdateWidgetService.new(widget, params)
service.map_to_model
service.valid? # => false
service.errors # => { "name" => #<ModelMapper::RecordError ...> }Each entry point has a class-method shortcut that builds the mapper (forwarding the initializer arguments) and runs it in one call, returning the mapper instance:
service = UpdateWidgetService.map_to_model!(widget, params)
# equivalent to:
service = UpdateWidgetService.new(widget, params).tap(&:map_to_model!)
service.widget # your own accessor
service.errors # combined errors (non-bang variants)map_to_model / map_to_model! / save_to_model / save_to_model! all have a shortcut; the !
variants raise exactly like their instance counterparts.
ModelMapper rules and the target's own validations are reported together, at
once, in one pass and one error shape — no separate save!-then-rescue step,
and no "fix the mapper errors first, then discover the model errors" round-trips.
The model is expected to carry the bulk of the validations; ModelMapper only
adds the rules a model cannot express (payload shape, referential checks against
a scope). On every call the mapper rules run and the target is assembled and
target.valid? is run, so both error sets come back in the same
ModelMapper::ValidationError (record errors wrapped as ModelMapper::RecordError,
one entry per attribute). A field already reported by a mapper rule is not
duplicated by the record error (the mapper owns the more specific rule).
This keeps ActiveRecord as the source of truth for everything it can express, with ModelMapper layered on top only for what it can't.
Note: the target is assembled and validated even when the mapper already found errors, so a
before_assignationhook runs on the validated subset — write it to tolerate partial data (don't dereference a referential that may have failed to map).
The save_* methods persist; the map_* methods never do. Persistence strategy
(in order of priority):
on_savehook if defined — replaces the default savetarget.save!(fromsave_to_model!) /target.save(fromsave_to_model) if the target responds to it- Raises
NotImplementedErrorotherwise
| Target type | Assignment | Default persistence |
|---|---|---|
| ActiveRecord model | assign_attributes |
save! |
| Hash | target[key] = value |
Requires on_save |
| PORO / Struct | target.name = value |
Requires on_save |
After validation, each attribute value is stored as an instance variable and exposed through a reader of the same name, so it can be read by name later in the same mapping:
attribute :name # -> @name = validated_value, and #nameAn association reference stores the resolved object under the association name:
association :category do
allowing -> { Category.enabled }
end
# -> @category = record, #category (and target.category = record)(Legacy :referential attributes ending in _id store both @category_id = record.id and
@category = record, with readers for each. A with-built association is assigned straight onto the
target and gets no reader.)
Readers are order-dependent. They are defined at declaration time (so they exist — returning
nil— even when the value is absent or failed to map, which lets amap_if/allowingreference them without raising). But a value is only populated once its attribute has been processed, and attributes are processed in declaration order. So a downstreamallowing/map_if/withlambda can read an earlier association (call_origindeclared first, read by a latercompany), but reading a later one silently yieldsnil. Declare references before the attributes that consume them.
Attributes with assign false are still stored as instance variables (and get a reader) — they are only excluded from assign_attributes.
Subclasses inherit and can selectively override parent attributes:
class ParentService
include ModelMapper
map_model do
from :@params
to :widget
attribute :name
attribute :status do
type :enumerated
allowing %w[active inactive]
end
end
end
class ChildService < ParentService
map_model do
attribute :status do
required true # adds required; type + allowing are inherited
end
attribute :code # new attribute
end
endThe child config is a deep copy — changes do not affect the parent.
When multiple attributes fail validation, all errors are collected and raised together in a single ValidationError:
begin
map_to_model!
rescue ModelMapper::ValidationError => e
e.errors # => { "name" => #<InvalidNilValueError>, "quantity" => #<InvalidFormatError> }
e.fields # => ["name", "quantity"]
e.first_error # => #<InvalidNilValueError>
e.message # => "name: ...; quantity: ..."
endThe same applies to record (ActiveRecord) errors once mapping is clean — they are
merged into the same ValidationError as ModelMapper::RecordError entries.
Attributes that validated successfully still have their instance variables set, even when the overall validation fails.
RuntimeError
|-- ModelMapper::InvalidValueError # value not in allowed set
| |-- ModelMapper::InvalidNilValueError # required value is nil/blank, or a null on `nullable false`
| |-- ModelMapper::UnauthorizedFieldError # caller may not write this field (`authorize`)
|-- ModelMapper::InvalidFormatError # wrong format (float, integer, date)
|-- ModelMapper::RecordError # wraps the target's own (ActiveRecord) error(s) for an attribute
|-- ModelMapper::ValidationError # wraps all errors (mapper + record) from a single call
All errors expose a field attribute (String, dot-separated key path like "infraction.zone.id"; nested associations/arrays use "vehicle.immat", "missions.0.driver").
ValidationError exposes:
errors—Hash<String, Error>of field => errorfields—Array<String>of failed field namesfirst_error— the first collected error
Superseded since 0.4.1. The old forms still work and emit a one-time warning; they will be
removed in a later release. See CHANGELOG.md.
| Deprecated | Replacement |
|---|---|
type :referential |
association :name do allowing … end (assigns the object, not the id) |
type :association (1‑1 record) |
association :name do with … end |
type :array + with (1‑N records) |
association :name, many: true do with … end |
type :array, of: :referential |
association :name, many: true do allowing … end |
field :col |
id_field :col |
condition -> { … } |
map_if -> { … } |
at :a, :b |
from :a, :b |
identifier :col, the name 0.4.1 gave id_field, is deprecated since 0.5.0: it
still works and warns at each declaration. Replace it with id_field :col.
type :array with a scalar of (:string, :integer, :float, :date, :boolean,
:enumerated, :custom, :any) is not deprecated.
A key sent with an explicit null, or with an empty list on a multi-valued param, on a param that
declares no nullable is still skipped, like an absent key, but a future release will clear the
value instead. Each such param emits, once per mapper class and param, whichever spelling it
receives first:
[ModelMapper] WidgetMapper: `nickname` received an explicit null, which is treated like an absent key
(skipped, or replaced by its `default`); a future release will clear the value instead. Declare
`nullable true` to clear it now, or `nullable false` to reject the null.
An empty list reads received an empty list, … and ends … or \nullable false` to reject it.`
A nested param names the payload path too: `status` (sent as `info.status`). A validation-only
param (assign false) does not warn: it assigns nothing either way.
Declare nullable true where the client means "clear it", nullable false where the record cannot
hold a null. A client that sends null or [] to mean "leave it alone" should stop sending the key.
A reference association, or an attribute assigned through a collection's ids writer (part_ids),
that does not declare immediate_write is still assigned when ActiveRecord writes that assignment to
the database at once (a has_one/has_many/HABTM of a persisted record,
with a value that differs from the current one), but a future release will refuse it instead. Each
such assignment emits, once per mapper class and param:
[ModelMapper] WidgetMapper: `parts` changed an association of a persisted record, which ActiveRecord
writes to the database immediately, during the map: it stays written even if the mapping then fails,
and old children are deleted under a deleting `dependent`. A future release will refuse it instead.
Declare `immediate_write false` to refuse it now, or `immediate_write true` to keep writing.
Declare immediate_write true where writing during the map is acceptable, immediate_write false
where it is not.
cd vendor/model_mapper
bundle install
bundle exec rake test