Serialization: Advanced attribute mapping

Mapping multiple names to a single attribute

The mapping methods support multiple names mapping to a single attribute using an array of names.

Syntax:

hsh | json | yaml | toml | key_value do
  map ["name1", "name2"], to: :attribute_name
end

xml do
  map_element ["name1", "name2"], to: :attribute_name
  map_attribute ["attr1", "attr2"], to: :attribute_name
end

When serializing, the first element in the array of mapped names is always used as the output name.

Example 1. Using multiple names to map to a single attribute
class CustomModel < Lutaml::Model::Serializable
  attribute :full_name, Lutaml::Model::Type::String
  attribute :color, Lutaml::Model::Type::String
  attribute :id, Lutaml::Model::Type::String

  json do
    map ["name", "custom_name"], with: { to: :name_to_json, from: :name_from_json }
    map ["color", "shade"], with: { to: :color_to_json, from: :color_from_json }
  end

  xml do
    element "CustomModel"
    map_element ["name", "custom-name"], with: { to: :name_to_xml, from: :name_from_xml }
    map_element ["color", "shade"], with: { to: :color_to_xml, from: :color_from_xml }
    map_attribute ["id", "identifier"], to: :id
  end

  # Custom methods for JSON
  def name_to_json(model, doc)
    doc["name"] = "JSON Model: #{model.full_name}"
  end

  def name_from_json(model, value)
    model.full_name = value&.sub(/^JSON Model: /, "")
  end

  def color_to_json(model, doc)
    doc["color"] = model.color.upcase
  end

  def color_from_json(model, value)
    model.color = value&.downcase
  end

  # Custom methods for XML
  def name_to_xml(model, parent, doc)
    el = doc.create_element("name")
    doc.add_text(el, "XML Model: #{model.full_name}")
    doc.add_element(parent, el)
  end

  def name_from_xml(model, value)
    model.full_name = value.sub(/^XML Model: /, "")
  end

  def color_to_xml(model, parent, doc)
    el = doc.create_element("color")
    doc.add_text(el, model.color.upcase)
    doc.add_element(parent, el)
  end

  def color_from_xml(model, value)
    model.color = value.downcase
  end
end

For JSON:

{
  "custom_name": "JSON Model: Vase",
  "shade": "BLUE",
  "identifier": "123"
}

For XML:

<CustomModel id="123">
  <name>XML Model: Vase</name>
  <color>BLUE</color>
</CustomModel>
> model = CustomModel.from_json(json)
> model.full_name
> # "Vase"
> model.color
> # "blue"

Attribute mapping delegation

Delegate attribute mappings to nested objects using the delegate option.

Syntax:

xml | hsh | json | yaml | toml do
  map 'key_value_model_attribute_name', to: :name_of_attribute, delegate: :model_to_delegate_to
end
Example 2. Using the delegate option to map attributes to nested objects

The following class will parse the JSON snippet below:

class Glaze < Lutaml::Model::Serializable
  attribute :color, :string
  attribute :temperature, :integer

  json do
    map 'color', to: :color
    map 'temperature', to: :temperature
  end
end

class Ceramic < Lutaml::Model::Serializable
  attribute :type, :string
  attribute :glaze, Glaze

  json do
    map 'type', to: :type
    map 'color', to: :color, delegate: :glaze
  end
end
{
  "type": "Porcelain",
  "color": "Clear"
}
> Ceramic.from_json(json)
> #<Ceramic:0x0000000104ac7240 @type="Porcelain", @glaze=#<Glaze:0x0000000104ac7240 @color="Clear", @temperature=nil>>
> Ceramic.new(type: "Porcelain", glaze: Glaze.new(color: "Clear")).to_json
> #{"type"=>"Porcelain", "color"=>"Clear"}
The corresponding keyword used by Shale is receiver: instead of delegate:.

Attribute serialization with custom methods

General

Define custom methods for specific attribute mappings using the with: key for each serialization mapping block for from and to.

XML serialization with custom methods

Syntax:

XML serialization with custom methods
xml do
  map_element 'element_name', to: :name_of_element, with: {
    to: :method_name_to_serialize,
    from: :method_name_to_deserialize
  }
  map_attribute 'attribute_name', to: :name_of_attribute, with: {
    to: :method_name_to_serialize,
    from: :method_name_to_deserialize
  }
  map_content, to: :name_of_content, with: {
    to: :method_name_to_serialize,
    from: :method_name_to_deserialize
  }
end
Example 3. Using the with: key to define custom serialization methods for XML

The following class will parse the XML snippet below:

class Metadata < Lutaml::Model::Serializable
  attribute :category, :string
  attribute :identifier, :string
end

class CustomCeramic < Lutaml::Model::Serializable
  attribute :name, :string
  attribute :size, :integer
  attribute :description, :string
  attribute :metadata, Metadata

  xml do
    map_element "Name", to: :name, with: { to: :name_to_xml, from: :name_from_xml }
    map_attribute "Size", to: :size, with: { to: :size_to_xml, from: :size_from_xml }
    map_content with: { to: :description_to_xml, from: :description_from_xml }
    map_element :metadata, to: :metadata, with: { to: :metadata_to_xml, from: :metadata_from_xml }
  end

  def name_to_xml(model, parent, doc)
    el = doc.create_element("Name")
    doc.add_text(el, "XML Masterpiece: #{model.name}")
    doc.add_element(parent, el)
  end

  def name_from_xml(model, value)
    model.name = value.sub(/^XML Masterpiece: /, "")
  end

  def size_to_xml(model, parent, doc)
    doc.add_attribute(parent, "Size", model.size + 3)
  end

  def size_from_xml(model, value)
    model.size = value.to_i - 3
  end

  def description_to_xml(model, parent, doc)
    doc.add_text(parent, "XML Description: #{model.description}")
  end

  def description_from_xml(model, value)
    model.description = value.join.strip.sub(/^XML Description: /, "")
  end

  def metadata_to_xml(model, parent, doc)
    metadata_el = doc.create_element("metadata")
    category_el = doc.create_element("category")
    identifier_el = doc.create_element("identifier")

    doc.add_text(category_el, model.metadata.category)
    doc.add_text(identifier_el, model.metadata.identifier)

    doc.add_element(metadata_el, category_el)
    doc.add_element(metadata_el, identifier_el)
    doc.add_element(parent, metadata_el)
  end

  def metadata_from_xml(model, value)
    model.metadata ||= Metadata.new

    model.metadata.category = value["elements"]["category"].text
    model.metadata.identifier = value["elements"]["identifier"].text
  end
end
<CustomCeramic Size="15">
  <Name>XML Masterpiece: Vase</Name>
  XML Description: A beautiful ceramic vase
  <metadata>
    <category>Metadata</category>
    <identifier>123</identifier>
  </metadata>
</CustomCeramic>
> CustomCeramic.from_xml(xml)
> #<CustomCeramic:0x0000000108d0e1f8
   @element_order=["text", "Name", "text", "Size", "text"],
   @name="Masterpiece: Vase",
   @ordered=nil,
   @size=12,
   @description="A beautiful ceramic vase",
   @metadata=#<Metadata:0x0000000105ad52e0 @category="Metadata", @identifier="123">>
> puts CustomCeramic.new(name: "Vase", size: 12, description: "A beautiful vase", metadata: Metadata.new(category: "Glaze", identifier: 15)).to_xml
# <CustomCeramic Size="15">
#   <Name>XML Masterpiece: Vase</Name>
#   <metadata>
#     <category>Glaze</category>
#     <identifier>15</identifier>
#   </metadata>
#   XML Description: A beautiful vase
# </CustomCeramic>
def custom_method_from_xml(model, value)
  instance = value.node # Lutaml::Model::Xml::AdapterElement
  # OR
  instance = value.node.adapter_node # Adapter::Element

  xml = instance.to_xml
end

When building a model from XML in custom methods, if the value parameter is a mapping_hash, then it allows access to the parsed XML structure through value.node which can be converted to an XML string using to_xml.

For NokogiriAdapter, we can also call to_xml on value.node.adapter_node.
> value
> # {"text"=>["\n    ", "\n    ", "\n  "], "elements"=>{"category"=>{"text"=>"Metadata"}}}
> value.to_xml
> # undefined_method `to_xml`

> value.node

# Nokogiri Adapter Node

#<Lutaml::Model::Xml::NokogiriElement:0x0000000107656ed8
#   @attributes={},
#   @children=
#   [#<Lutaml::Model::Xml::NokogiriElement:0x0000000107656cd0 @attributes={}, @children=[], @default_namespace=nil, @name="text", @namespace_prefix=nil, @text="\n    ">,
#   #<Lutaml::Model::Xml::NokogiriElement:0x00000001076569b0
#     @attributes={},
#     @children=
#     [#<Lutaml::Model::Xml::NokogiriElement:0x00000001076567f8 @attributes={}, @children=[], @default_namespace=nil, @name="text", @namespace_prefix=nil, @text="Metadata">],
#     @default_namespace=nil,
#     @name="category",
#     @namespace_prefix=nil,
#     @text="Metadata">,
#   #<Lutaml::Model::Xml::NokogiriElement:0x0000000107656028 @attributes={}, @children=[], @default_namespace=nil, @name="text", @namespace_prefix=nil, @text="\n  ">],
# @default_namespace=nil,
# @name="metadata",
# @namespace_prefix=nil,
# @text="\n    Metadata\n  ">

# Ox Adapter Node

#<Lutaml::Model::Xml::OxElement:0x0000000107584f78
# @attributes={},
# @children=
#   [#<Lutaml::Model::Xml::OxElement:0x0000000107584e60
#     @attributes={},
#     @children=[#<Lutaml::Model::Xml::OxElement:0x0000000107584d48 @attributes={}, @children=[], @default_namespace=nil, @name="text", @namespace_prefix=nil, @text="Metadata">],
#     @default_namespace=nil,
#     @name="category",
#     @namespace_prefix=nil,
#     @text="Metadata">],
# @default_namespace=nil,
# @name="metadata",
# @namespace_prefix=nil,
# @text=nil>

# Oga Adapter Node

# <Lutaml::Model::Xml::Oga::Element:0x0000000107314158
# @attributes={},
# @children=
#   [#<Lutaml::Model::Xml::Oga::Element:0x0000000107314090 @attributes={}, @children=[], @default_namespace=nil, @name="text", @namespace_prefix=nil, @text="\n    ">,
#   #<Lutaml::Model::Xml::Oga::Element:0x000000010730fe78
#     @attributes={},
#     @children=[#<Lutaml::Model::Xml::Oga::Element:0x000000010730fd88 @attributes={}, @children=[], @default_namespace=nil, @name="text", @namespace_prefix=nil, @text="Metadata">],
#     @default_namespace=nil,
#     @name="category",
#     @namespace_prefix=nil,
#     @text="Metadata">,
#   #<Lutaml::Model::Xml::Oga::Element:0x000000010730f8d8 @attributes={}, @children=[], @default_namespace=nil, @name="text", @namespace_prefix=nil, @text="\n  ">],
# @default_namespace=nil,
# @name="metadata",
# @namespace_prefix=nil,
# @text="\n    Metadata\n  ">

> value.node.to_xml
> #<metadata><category>Metadata</category></metadata>

Key-value data model serialization with custom methods

Key-value data model serialization with custom methods
hsh | json | yaml | toml do
  map 'attribute_name', to: :name_of_attribute, with: {
    to: :method_name_to_serialize,
    from: :method_name_to_deserialize
  }
end
Example 4. Using the with: key to define custom serialization methods

The following class will parse the JSON snippet below:

class CustomCeramic < Lutaml::Model::Serializable
  attribute :name, :string
  attribute :size, :integer

  json do
    map 'name', to: :name, with: { to: :name_to_json, from: :name_from_json }
    map 'size', to: :size
  end

  def name_to_json(model, doc)
    doc["name"] = "Masterpiece: #{model.name}"
  end

  def name_from_json(model, value)
    model.name = value.sub(/^Masterpiece: /, '')
  end
end
{
  "name": "Masterpiece: Vase",
  "size": 12
}
> CustomCeramic.from_json(json)
> #<CustomCeramic:0x0000000104ac7240 @name="Vase", @size=12>
> CustomCeramic.new(name: "Vase", size: 12).to_json
> #{"name"=>"Masterpiece: Vase", "size"=>12}

Only One Custom Method

Only one custom method can be added for the serialization or deserialization of an attribute.

Syntax:

xml do
  map_element 'element_name', to: :name_of_element, with: {
    to: :method_name_to_serialize # only 'to' is implemented
  }
  map_element 'element_name', to: :name_of_element, with: {
    from: :method_name_to_deserialize # only 'from' is implemented
  }
end

hsh | json | yaml | toml do
  map 'attribute_name', to: :name_of_attribute, with: {
    to: :method_name_to_serialize # only 'to' is implemented
  }
  map 'attribute_name', to: :name_of_attribute, with: {
    from: :method_name_to_deserialize # only 'from' is implemented
  }
end

This is only applicable if the to: :name_of_element (in xml mapping) or to: :name_of_attribute (in key_value mapping) option is set. If it is not set, then both custom methods must be provided.

class CustomCeramic < Lutaml::Model::Serializable
  attribute :name, :string
  attribute :size, :integer

  xml do
    map_element "Name", to: :name, with: { to: :name_to_xml }
    map_attribute "Size", to: :size
  end

  json do
    map 'name', to: :name, with: { from: :name_from_json }
    map 'size', to: :size
  end

  def name_to_xml(model, parent, doc)
    el = doc.create_element("Name")
    doc.add_text(el, "XML Masterpiece: #{model.name}")
    doc.add_element(parent, el)
  end

  def name_from_json(model, value)
    model.name = value.sub(/^Masterpiece: /, '')
  end
end
<CustomCeramic Size="15">
  <Name>Vase</Name>
</CustomCeramic>
> CustomCeramic.from_xml(xml)
> #<CustomCeramic:0x0000000108d0e1f8
   @element_order=["text", "Name", "text", "Size", "text"],
   @name="Vase",
   @ordered=nil,
   @size=15>
> puts CustomCeramic.new(name: "Vase", size: 15).to_xml
# <CustomCeramic Size="15">
#   <Name>XML Masterpiece: Vase</Name>
# </CustomCeramic>
{
  "name": "Masterpiece: Vase",
  "size": 12
}
> CustomCeramic.from_json(json)
> #<CustomCeramic:0x0000000104ac7240 @name="Vase", @size=12>
> CustomCeramic.new(name: "Vase", size: 12).to_json
> # {"name":"Vase","size":12}

Attribute-value dispatch on a shared element name

When several distinct attributes share one wire element, selected by a sibling attribute’s value, declare the discriminator key with when_attribute: and map each value to its attribute:

class Requirement < Lutaml::Model::Serializable
  attribute :guidance, Component, collection: true
  attribute :purpose, Component, collection: true

  xml do
    element "requirement"
    map_element "component", when_attribute: "type",
                             to: { "guidance" => :guidance,
                                   "purpose" => :purpose }
  end
end

That one declaration expands, at DSL time, to one rule per value — identical to writing each rule by hand:

xml do
  element "requirement"
  map_element "component", to: :guidance,
                           when_attribute: { "type" => "guidance" }
  map_element "component", to: :purpose,
                           when_attribute: { "type" => "purpose" }
end

Use the per-rule form when a partition is partial (only some values claimed) or a rule needs several pairs — multiple pairs mean AND.

Each rule hydrates only from the <component> occurrences whose type attribute carries the expected value, in document order; uncovered values are ignored by all discriminator rules. On serialization the discriminator pair is re-emitted on the element (unless the value’s own mapping already writes that attribute).

Co-occurrence semantics: several when_attribute rules may share the element name; each target attribute fills independently, so declare each as collection: true when the element repeats. A rule without when_attribute that shares the name only claims occurrences no discriminator rule claimed, so every occurrence is captured exactly once.

On the key-value formats the same coexistence applies to when_attribute rules sharing one key, regardless of declaration order. A rule without when_attribute is an unconditional claim on the bare slot: declaring one replaces prior bare rules on that key — including a parent class’s rule the subclass remaps to a different attribute — while when_attribute partitions on the key survive. A partition rule replaces only a prior partition with the same target; otherwise it accumulates.

By default an occurrence no rule claims — an uncovered discriminator value, or a missing discriminator attribute — is silently dropped. Declare unmatched: :raise on a discriminator rule to fail the parse instead: Lutaml::Model::UnknownDiscriminatorError names the element and the unclaimed discriminator values. A plain rule sharing the name claims everything left, so the strict policy never fires when one is present.

xml do
  element "requirement"
  map_element "component", when_attribute: "type",
                           to: { "guidance" => :guidance },
                           unmatched: :raise
end

when_attribute and polymorphic: are two different dispatch axes over the same discriminator idea — pick by what the discriminator selects:

  • polymorphic: { attribute:, class_map: } — the value selects the class each occurrence hydrates into; ONE attribute holds every occurrence. See the polymorphic models tutorial.

  • when_attribute: — the value selects which attribute claims the occurrence; the declared type stays fixed.

They compose: a rule may partition with when_attribute while its attribute dispatches classes with polymorphic.

Under an ordered mapping, serialization preserves the original interleaving: element_order records each occurrence’s attributes, and the ordered walk routes every occurrence back to the rule whose discriminator it satisfies.

xml do
  element "requirement"
  ordered
  map_element "component", when_attribute: "type",
                           to: { "guidance" => :guidance,
                                 "purpose" => :purpose }
end

Given <component type="guidance">, <component type="purpose">, <component type="guidance"> in the source, to_xml re-emits exactly that guidance, purpose, guidance sequence.

Key-value formats

The same discriminator works for JSON, YAML, TOML and Hash: the wire name is an object key, and the discriminator is a sibling key’s value inside each item.

class KvRequirement < Lutaml::Model::Serializable
  attribute :guidance, Component, collection: true
  attribute :purpose, Component, collection: true

  json do
    map "component", when_attribute: "type",
                     to: { "guidance" => :guidance, "purpose" => :purpose }
  end
end
{ "component": [
  { "type": "guidance", "text": "g1" },
  { "type": "purpose", "text": "p1" }
] }

guidance holds g1 and purpose holds p1. A bare object instead of an array counts as one occurrence. Every semantics from the XML side carries over: plain rules on the same key capture only unclaimed items, unmatched: :raise fails the parse on an item no rule claims, and string or symbol keys both match.

On serialization the rules merge back under the one key, each item stamped with its rule’s discriminator pair (unless the item’s own mapping already writes that key):

{ "component": [
  { "type": "guidance", "text": "g1" },
  { "type": "purpose", "text": "p1" }
] }

Items are grouped by rule in mapping-declaration order — object documents carry no interleaving to preserve. when_attribute cannot be combined with with: or delegate: in key-value mappings.