Skip to content

Repository files navigation

ModelMapper

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.

Installation

# Gemfilegem'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.

Quick Start

classUpdateWidgetServiceincludeModelMapper# No initialize, no from/to needed: ModelMapper provides a standard initializer and `from`/`to`# default to the mapped `record` / `params`.map_modeldorecord_alias:widget# optional: expose #widget as an alias of #recordattribute:namedorequiredtrueendattribute:statusdofrom:info,:status# dig into nested hashtype:enumeratedallowing%w[activeinactivearchived]endendendwidget=Widget.find(1)service=UpdateWidgetService.new(widget,params,user: current_user)# extra kwargs → readersservice.map_to_model!# validate (mapper + record) + assign; raises on invalid, never persistsservice.widget.save!# persistence stays in the caller's hands (or use save_to_model!)

Initialization

include ModelMapper provides a standard initializer — Mapper.new(record, params, **context):

  • record → the mapped object (#record), default target of to.
  • params → the source hash (#params), default source of from.
  • 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.

DSL: map_model

The map_model block configures the mapping. It is evaluated at the class level.

from / to

Optional — they default to :@params and :@record (the standard initializer's params / record). Declare them only to read/write elsewhere.

map_modeldofrom:@params# instance variable (default)from:params# method callto:widget# method callto:@widget# instance variableend

Both accept a Symbol. Symbols starting with @ are resolved as instance variables; others are sent as method calls on the service instance.

Associations: association

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_modeldo# 1‑1 reference — link an existing, scoped record; the OBJECT is assigned (not the id).association:call_origindoallowing->{CallOrigin.where(company: user.companies)}requiredtrueend# 1‑1 build — nested record built by a sub-mapper (via accepts_nested_attributes_for).association:vehicle_attributesdofrom:vehiclewithVehicleMapper,->{{company: call_origin.company}}end# 1‑N build.association:missions_attributes,many: truedofrom:missionswithMissionMapper,->{{company: call_origin.company}}end# 1‑N reference — link existing, scoped records; the OBJECTS are assigned.association:tags,many: truedoallowing->{current_company.tags}end# Upsert — an id ∈ `allowing` updates that record in place; no id builds a new one.association:missions_attributes,many: truedofrom:missionswithMissionMapper,->{{company: call_origin.company}}allowing->{call_origin.company.missions}endend

Two 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_origincall_origin=, the object); in build mode you name the nested-attributes writer (:vehicle_attributesvehicle_attributes=, so the model still needs accepts_nested_attributes_for :vehicle).
  • Cardinality — 1‑1 by default, many: true for 1‑N. (Not inferred from ActiveRecord.)
  • Resolutionallowing (reference) and/or with (build); composing both gives upsert.

Resolution, per value (or per element when many: true):

allowingwithid in payloadbehaviour
value absent → skipped (or required → error)
yesnoyeslink — id validated ∈ allowing; the OBJECT is assigned
yesnonoskipped (or required → error) — an id is required to link
noyes(ignored)build (create) via the sub-mapper — any id in the payload is ignored
yesyesyesupdate — id validated ∈ allowing; that record is attached and updated in place
yesyesnobuild (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. with alone 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 through accepts_nested_attributes_for (declare it on the parent).

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 genuine replacement in the caller. 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 true to demand it. Out-of-scope or unknown ids are rejected (never silently dropped), keyed on the params path (call_origin.id, missions.2.id).
  • 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.id is 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.

Arrays of scalars: type :array + of

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:numbersdotype:arrayof:integer# ["1","2"] => [1, 2]; a non-integer element => "numbers.<i>" errorendattribute:statusesdotype:arrayof:enumeratedallowing%w[openclosed]# the attribute's `allowing` is applied to each elementend
  • The attribute's allowing applies to every element (so :enumerated elements share one scope).
  • An empty array is treated as blank (like any empty value): dropped when optional, an error when required.

Use association, not type :array, for arrays of records or referenced ids.type :array with with (array of records) and of :referential (array of referenced ids) are deprecated in favour of association …, many: true (see Associations above and Deprecations below). type :array with a scalar of (:string, :integer, …) stays.

attribute

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-throughattribute:pricedo# with optionstype:floatrequiredtrueend

(To link/build records, use association — see above.)

Deprecation:at is the former name of from and still works, but emits a deprecation warning. Prefer from.

Attribute / association options

from, id_field, allowing, with, required, assign, default, default_on_invalid, of, type, multiple are block methods; many: and the cardinality belong to association. map_if is a block method (see below).

OptionTypeDefaultDescription
from*keys[name]Key path for Hash#dig into the source params (the section, for an association) — was at
id_fieldSymbol:idReference/upsert: the key read inside the from section and the find_by column — replaces field
allowingvariousnilAllowed values/records — Array, AR scope, or lambda. Reference mode of association; also :enumerated/:custom
withclass, lambdanilSub-mapper (+ optional context lambda) for an association built/upserted via accepts_nested_attributes_for
many:Boolfalseassociation cardinality — true ⇒ 1‑N (kwarg on association)
map_ifProcnilBlock method controlling whether the attribute/association is processed — replaces condition
requiredBool / ProcfalseWhether nil/blank raises an error
assignBooltrueInclude in assign_attributes; false = validate-only (was save, which now raises)
defaultvalue / ProcnilFallback when the source value is nil/missing
default_on_invalidBoolfalseUse default when validation fails instead of raising
typeSymbolnilScalar validation type (:integer, :float, :date, :boolean, :enumerated, :custom) or :array
ofSymbolnilElement type for a type :array of scalars
multipleBoolfalse:enumerated only — accept an array of values
fieldSymbol:idDeprecated — use id_field
conditionProcnilDeprecated — use map_if

required

Accepts true, false, or a lambda:

requiredtruerequired->(params){params[:code].nil?}required->(params,target){target.new_record?}

default

Accepts a static value or a lambda:

default'active'default->{Time.current}default->(params){params[:fallback_name]}

map_if

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:statusdomap_if->{@include_status}end# With arguments:map_if->(target){target.new_record?}map_if->(target,source_params){source_params.key?(:status)}

Deprecation:condition is the former name and still works, but emits a deprecation warning. Prefer map_if.

Validation Types

:enumerated

Validates the value against a list of allowed values.

attribute:statusdotype:enumeratedallowing%w[activeinactivearchived]end# With multiple values:attribute:tagsdotype:enumeratedallowing%w[urgentnormallow]multipletrueend

:referential(deprecated — use association … allowing)

Looks up an ActiveRecord record by the given value and assigns its id. Superseded by the association reference mode, which assigns the object and reads the section + id_field:

# beforeattribute:zone_iddofrom:zone,:idtype:referentialallowingZone.enabledend# afterassociation:zonedofrom:zoneallowing->{Zone.enabled}end

A custom lookup column was field :name; it is now id_field :name.

Unchanged-value tolerance (legacy :referential only): if the target already has the same value, the record is allowed even if it no longer matches allowing (e.g. soft-deleted). The new association reference mode does not yet carry this tolerance.

:custom

Passes the value through a lambda for arbitrary validation/transformation. The lambda receives (value, source_params) and is executed via instance_exec.

attribute:codedotype:customallowing->(value,_params){value.upcase}end

Return the transformed value. Raise ModelMapper::InvalidValueError to reject.

:float

Validates and converts to Float.

attribute:pricedotype:floatend

:integer

Validates and converts to Integer. Rejects non-integer strings (e.g. "12.5").

attribute:quantitydotype:integerend

:date

Parses via Time.zone.iso8601. Raises InvalidFormatError on failure.

attribute:scheduled_atdotype:dateend

:boolean

Converts via .to_bool (truthy/falsy string conversion).

attribute:activedotype:booleanend

Hooks

Hooks are declared inside the map_model block and executed via instance_exec on the service instance.

Execution Order

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!

Signatures

map_modeldobefore_validationdo |source_params,target_object|
# Runs before the validation loop. Modify source or target in-place.endbefore_assignationdo |source_params,validated_params|
# Runs after validation, before assign_attributes.# validated_params is a Hash you can mutate.endbefore_savedo |source_params|
# Runs before persistence.endafter_savedo |source_params|
# Runs after persistence.endon_savedo |target|
# Replaces the default target.save! call.target.update_columns(...)endend

Entry points

Four 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 invalidsave_to_model# validate + assign + save (skipped when invalid); non-raisingsave_to_model!# validate + assign + save! ; raises if invalid or on save failure

After a non-bang call, inspect the result on the mapper instance:

service=UpdateWidgetService.new(widget,params)service.map_to_modelservice.valid?# => falseservice.errors# => { "name" => #<ModelMapper::RecordError ...> }

Class-method shortcuts

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 accessorservice.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.

Combined validation

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_assignation hook runs on the validated subset — write it to tolerate partial data (don't dereference a referential that may have failed to map).

Persistence

The save_* methods persist; the map_* methods never do. Persistence strategy (in order of priority):

  1. on_save hook if defined — replaces the default save
  2. target.save! (from save_to_model!) / target.save (from save_to_model) if the target responds to it
  3. Raises NotImplementedError otherwise

Supported Targets

Target typeAssignmentDefault persistence
ActiveRecord modelassign_attributessave!
Hashtarget[key] = valueRequires on_save
PORO / Structtarget.name = valueRequires on_save

Instance Variables

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 #name

An association reference stores the resolved object under the association name:

association:categorydoallowing->{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 a map_if/allowing reference them without raising). But a value is only populated once its attribute has been processed, and attributes are processed in declaration order. So a downstream allowing/map_if/with lambda can read an earlier association (call_origin declared first, read by a later company), but reading a later one silently yields nil. 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.

Inheritance

Subclasses inherit and can selectively override parent attributes:

classParentServiceincludeModelMappermap_modeldofrom:@paramsto:widgetattribute:nameattribute:statusdotype:enumeratedallowing%w[activeinactive]endendendclassChildService < ParentServicemap_modeldoattribute:statusdorequiredtrue# adds required; type + allowing are inheritedendattribute:code# new attributeendend

The child config is a deep copy — changes do not affect the parent.

Multi-Error Validation

When multiple attributes fail validation, all errors are collected and raised together in a single ValidationError:

beginmap_to_model!rescueModelMapper::ValidationError=>ee.errors# => { "name" => #<InvalidNilValueError>, "quantity" => #<InvalidFormatError> }e.fields# => ["name", "quantity"]e.first_error# => #<InvalidNilValueError>e.message# => "name: ...; quantity: ..."end

The 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.

Errors

RuntimeError
|-- ModelMapper::InvalidValueError # value not in allowed set
| |-- ModelMapper::InvalidNilValueError # required value is nil/blank
|-- 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:

  • errorsHash<String, Error> of field => error
  • fieldsArray<String> of failed field names
  • first_error — the first collected error

Deprecations

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.

DeprecatedReplacement
type :referentialassociation :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: :referentialassociation :name, many: true do allowing … end
field :colid_field :col
condition -> { … }map_if -> { … }
at :a, :bfrom :a, :b

type :array with a scalar of (:string, :integer, :float, :date, :boolean, :enumerated, :custom, :any) is not deprecated.

Tests

cd vendor/model_mapper
bundle install
bundle exec rake test

About

A declarative DSL for mapping Hash/JSON parameters to Ruby objects with type validation, referential integrity checks, and optional persistence. Designed for service objects that receive external input and need to validate, transform, and assign it to a model.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages