lutaml-model 0.8.41 → 0.8.43

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.
@@ -527,6 +527,7 @@ module Lutaml
527
527
  nil),
528
528
  transform: {},
529
529
  value_map: {},
530
+ when_attribute: {},
530
531
  form: nil,
531
532
  documentation: nil,
532
533
  xsd_type: (xsd_type_provided = false
@@ -537,6 +538,8 @@ module Lutaml
537
538
  name, to, with, render_nil, render_empty, type: TYPES[:element]
538
539
  )
539
540
 
541
+ validate_when_attribute!(when_attribute) unless when_attribute.empty?
542
+
540
543
  # Warn if prefix parameter is provided
541
544
  if prefix_provided != false
542
545
  warn "[DEPRECATED] The prefix parameter on map_element is deprecated. " \
@@ -584,6 +587,7 @@ module Lutaml
584
587
  form: form,
585
588
  documentation: documentation,
586
589
  raw: raw,
590
+ when_attribute: when_attribute,
587
591
  )
588
592
  # Store rules with the same element name in an array to support
589
593
  # multiple mapping rules for the same element name with different target types
@@ -607,6 +611,20 @@ module Lutaml
607
611
  end
608
612
  end
609
613
 
614
+ # lutaml-model#88: attribute-value discriminator. Keys are wire
615
+ # attribute names, values the expected string value.
616
+ def validate_when_attribute!(when_attribute)
617
+ when_attribute.each do |k, v|
618
+ next if (k.is_a?(::String) || k.is_a?(::Symbol)) &&
619
+ (v.is_a?(::String) || v.is_a?(::Symbol))
620
+
621
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
622
+ "when_attribute expects string/symbol attribute names " \
623
+ "mapped to string/symbol values, got " \
624
+ "#{k.inspect} => #{v.inspect}"
625
+ end
626
+ end
627
+
610
628
  def map_attribute(
611
629
  name,
612
630
  to: nil,
@@ -1150,7 +1168,8 @@ module Lutaml
1150
1168
  grouped = {}
1151
1169
  mappings(reg_key).each do |r|
1152
1170
  next if r.attribute? || r.raw_mapping? || r.content_mapping? ||
1153
- r.cdata || r.has_custom_method_for_deserialization? ||
1171
+ r.cdata || r.when_attribute? ||
1172
+ r.has_custom_method_for_deserialization? ||
1154
1173
  (r.transform.is_a?(Hash) && !r.transform.empty?) ||
1155
1174
  r.transform.is_a?(Class)
1156
1175
 
@@ -16,7 +16,7 @@ module Lutaml
16
16
  # Writers for deep_dup (preserves exact object references)
17
17
  attr_accessor :namespace, :prefix, :namespace_class
18
18
 
19
- def initialize(
19
+ def initialize( # rubocop:disable Metrics/ParameterLists -- mirrors the DSL surface
20
20
  name,
21
21
  to:,
22
22
  render_nil: false,
@@ -37,6 +37,7 @@ module Lutaml
37
37
  polymorphic_map: {},
38
38
  transform: {},
39
39
  value_map: {},
40
+ when_attribute: {},
40
41
  as_list: nil,
41
42
  delimiter: nil,
42
43
  form: nil,
@@ -59,6 +60,7 @@ module Lutaml
59
60
  polymorphic_map: polymorphic_map,
60
61
  transform: transform,
61
62
  value_map: value_map,
63
+ when_attribute: when_attribute,
62
64
  )
63
65
 
64
66
  # Store original namespace parameter to preserve :inherit symbol
@@ -225,6 +227,15 @@ module Lutaml
225
227
  end
226
228
  end
227
229
 
230
+ # Whether a parsed element satisfies this rule's when_attribute
231
+ # discriminator: every expected attribute value matches.
232
+ def matches_when_attribute?(element)
233
+ when_attribute.all? do |name, expected|
234
+ actual = element.find_attribute_value(name.to_s)
235
+ !actual.nil? && actual.to_s == expected.to_s
236
+ end
237
+ end
238
+
228
239
  def deep_dup
229
240
  # Preserve @namespace_param exactly as it was (string, Class, :inherit, or nil)
230
241
  # This ensures the duplicate has the same internal state as the original
@@ -261,6 +272,7 @@ module Lutaml
261
272
  form: @form,
262
273
  documentation: @documentation,
263
274
  raw: @raw,
275
+ when_attribute: when_attribute,
264
276
  ).tap do |dup_rule|
265
277
  # Manually preserve the exact @namespace_class object to avoid
266
278
  # recreating anonymous classes (which would have different object_ids)
@@ -40,6 +40,10 @@ module Lutaml
40
40
  # Performance: Frozen empty hash to reduce allocations
41
41
  EMPTY_HASH = {}.freeze
42
42
 
43
+ # ":".getbyte(0) — getbyte comparisons avoid the per-candidate
44
+ # string slice in namespace-prefix checks on the lenient paths.
45
+ COLON_BYTE = 58
46
+
43
47
  def data_to_model(data, _format, options = {})
44
48
  # Use child's own default register if it has one
45
49
  # This ensures versioned schemas (e.g., MML v2 with lutaml_default_register = :mml_v2)
@@ -315,6 +319,13 @@ module Lutaml
315
319
  compiled_rule_records(instance.class, effective_register,
316
320
  grouped_plain_rules)
317
321
  end
322
+ # Loop invariants: the entity-decode flag is a per-(class, register)
323
+ # fact and the attributes hash is memoized per register — both were
324
+ # re-asked per rule application (942k + 751k calls per ISO-13849
325
+ # parse).
326
+ decode_entities_flag = decode_html_entities_for(instance,
327
+ effective_register)
328
+ deserialize_attributes = attributes
318
329
  mappings.each do |rule|
319
330
  record = records&.[](rule)
320
331
  rule_to = rule.to
@@ -376,6 +387,9 @@ module Lutaml
376
387
  default_namespace)
377
388
  # Pre-match: no child element matches this rule.
378
389
  # Skip expensive value_for_rule, handle defaults inline.
390
+ # NOTE: do not "read the instance" here (to_value_for) —
391
+ # the compiled getters materialize lazy collections on
392
+ # first read, which tripled parse allocations when tried.
379
393
  if instance.using_default?(rule_to) || rule.render_default
380
394
  defaults_used << rule_to
381
395
  attr&.default(effective_register) || rule.to_value_for(instance)
@@ -409,9 +423,7 @@ module Lutaml
409
423
  value = apply_value_map(value, from_map, attr)
410
424
  value = normalize_xml_value(value, rule, attr, new_opts,
411
425
  effective_register)
412
- value = decode_html_entities_value(value, decode_html_entities_for(
413
- instance, effective_register
414
- ))
426
+ value = decode_html_entities_value(value, decode_entities_flag)
415
427
  value = rule.transform_value(attr, value, :from, :xml)
416
428
  # An over-count is normally left for `.validate` to report. A mapped
417
429
  # PORO has no `.validate`, so deferring there would discard the
@@ -420,7 +432,7 @@ module Lutaml
420
432
  !rule.content_mapping?
421
433
  attr.valid_collection!(value, context)
422
434
  end
423
- rule.deserialize(instance, value, attributes, context,
435
+ rule.deserialize(instance, value, deserialize_attributes, context,
424
436
  options[:context])
425
437
 
426
438
  instance.value_set_for(rule_to)
@@ -592,6 +604,21 @@ _effective_register)
592
604
  def convert_rule_name_to_attribute_name(doc, rule_name, session: nil)
593
605
  return nil unless rule_name.include?(":")
594
606
 
607
+ # Pure function of (element attributes, spelling) — the same
608
+ # conversions repeat per rule application (~450k find/slice
609
+ # allocations per ISO-13849 parse before this memo).
610
+ conversions = session&.rule_name_resolution(doc.root)
611
+ if conversions
612
+ return conversions[rule_name] if conversions.key?(rule_name)
613
+
614
+ converted = convert_rule_name_uncached(doc, rule_name, session)
615
+ conversions[rule_name] = converted
616
+ else
617
+ convert_rule_name_uncached(doc, rule_name, session)
618
+ end
619
+ end
620
+
621
+ def convert_rule_name_uncached(doc, rule_name, session)
595
622
  # URI-vs-prefix detection works on the whole rule name: the
596
623
  # namespace part is a prefix of rule_name, so "://" anywhere in
597
624
  # the name means a URI-form namespace, and a "urn:" namespace
@@ -612,10 +639,13 @@ _effective_register)
612
639
  candidates = session.element_local_attribute_index(doc.root)[local_name]
613
640
  # No candidate carries this local name: nothing in the
614
641
  # element can match, the scan is skipped entirely.
642
+ # getbyte, not a [i] slice — the slice allocated one
643
+ # string per probed candidate (~380k per ISO-13849 parse).
615
644
  matched = candidates&.find do |attr|
616
645
  ns = attr.namespace
617
646
  next false if ns.nil? || ns.length > last_colon_index
618
- next false unless rule_name.start_with?(ns) && rule_name[ns.length] == ":"
647
+ next false unless rule_name.start_with?(ns) &&
648
+ rule_name.getbyte(ns.length) == COLON_BYTE
619
649
 
620
650
  attr.unprefixed_name == local_name
621
651
  end
@@ -623,7 +653,8 @@ _effective_register)
623
653
  matched = doc.root.attributes.each_value.find do |attr|
624
654
  ns = attr.namespace
625
655
  next false if ns.nil? || ns.length > last_colon_index
626
- next false unless rule_name.start_with?(ns) && rule_name[ns.length] == ":"
656
+ next false unless rule_name.start_with?(ns) &&
657
+ rule_name.getbyte(ns.length) == COLON_BYTE
627
658
 
628
659
  attr.unprefixed_name == local_name
629
660
  end
@@ -653,13 +684,27 @@ _effective_register)
653
684
  def find_attribute_by_local_name(doc, rule_names,
654
685
  flexible_local: false, session: nil)
655
686
  root_index = session&.element_local_attribute_index(doc.root)
687
+ # Matched VALUE per (element, spelling, flexible flag) — pure
688
+ # while the element is parse-frozen.
689
+ memo = if session
690
+ session.lenient_local_matches(doc.root,
691
+ flexible_local)
692
+ else
693
+ nil
694
+ end
656
695
 
657
696
  rule_names.each do |rule_name|
658
697
  next unless rule_name.include?(":")
659
698
 
660
- resolved = match_attribute_by_local_name(
661
- doc, rule_name, root_index, flexible_local
662
- )
699
+ resolved = if memo&.key?(rule_name)
700
+ memo[rule_name]
701
+ else
702
+ match = match_attribute_by_local_name(
703
+ doc, rule_name, root_index, flexible_local
704
+ )
705
+ memo[rule_name] = match if memo
706
+ match
707
+ end
663
708
  return resolved if resolved
664
709
  end
665
710
  nil
@@ -678,6 +723,11 @@ _effective_register)
678
723
  local_name, rule_uri = parts
679
724
 
680
725
  candidates = root_index&.[](local_name)
726
+ # The index carries every local form of every attribute on the
727
+ # element — a miss means no attribute can match, so the full
728
+ # each_value scan below is dead whenever the index exists.
729
+ return nil if root_index && !candidates
730
+
681
731
  matched_attr = if candidates
682
732
  candidates.find do |attr|
683
733
  local_name_match?(attr, local_name, rule_uri,
@@ -786,10 +836,27 @@ _effective_register)
786
836
  end
787
837
 
788
838
  def decode_html_entities_for(instance, register)
839
+ # Asked per rule application (942k per ISO-13849 parse) for a
840
+ # per-(class, register) fact; the transform instance itself is
841
+ # cached per (model class, register), so the memo lives here.
842
+ per_class = (@decode_html_entities_cache ||= {}.compare_by_identity)
843
+ per_register = (per_class[instance.class] ||= {})
844
+ return per_register[register] if per_register.key?(register)
845
+
789
846
  klass = instance.class
790
- return false unless klass.is_a?(Class) && klass.include?(Lutaml::Model::Serialize)
847
+ per_register[register] = klass.is_a?(Class) &&
848
+ klass.include?(Lutaml::Model::Serialize) &&
849
+ (klass.mappings_for(:xml, register)&.decode_html_entities? || false)
850
+ end
791
851
 
792
- klass.mappings_for(:xml, register)&.decode_html_entities?
852
+ # Serializable type class → its XML namespace class, asked per
853
+ # (rule × element) in value_for_rule / resolve_rule_names_with_type
854
+ # for a per-type static.
855
+ def type_namespace_class_of(type_class)
856
+ cache = (@type_ns_class_cache ||= {}.compare_by_identity)
857
+ return cache[type_class] if cache.key?(type_class)
858
+
859
+ cache[type_class] = type_class.mappings_for(:xml)&.namespace_class
793
860
  end
794
861
 
795
862
  def value_for_rule(session, rule, options, cached_attr = nil,
@@ -847,7 +914,7 @@ _effective_register)
847
914
  type_ns_all_uris = nil
848
915
  if attr_type_is_serializable
849
916
  type_ns_class = if attr_type_is_class
850
- attr_type.mappings_for(:xml)&.namespace_class
917
+ type_namespace_class_of(attr_type)
851
918
  else
852
919
  attr.type_namespace_class(effective_register)
853
920
  end
@@ -984,6 +1051,15 @@ _effective_register)
984
1051
  end
985
1052
  end
986
1053
 
1054
+ # lutaml-model#88: attribute-value discriminator — among the
1055
+ # name-matched occurrences, keep only those whose sibling
1056
+ # attributes carry the expected values.
1057
+ if rule.when_attribute?
1058
+ children = children.select do |child|
1059
+ rule.matches_when_attribute?(child)
1060
+ end
1061
+ end
1062
+
987
1063
  # Performance: Cache rule method check before loop to avoid repeated method calls
988
1064
  rule_has_custom_method = rule.has_custom_method_for_deserialization?
989
1065
  if rule_has_custom_method || attr_type == ::Lutaml::Model::Type::Hash
@@ -1292,7 +1368,7 @@ effective_register = lutaml_register)
1292
1368
  elsif precomputed_type.is_a?(Class) &&
1293
1369
  precomputed_type.include?(::Lutaml::Model::Serialize)
1294
1370
  # Use pre-computed type directly — avoids redundant attr.type() call
1295
- attr_type_ns_class = precomputed_type.mappings_for(:xml)&.namespace_class
1371
+ attr_type_ns_class = type_namespace_class_of(precomputed_type)
1296
1372
  if attr_type_ns_class
1297
1373
  return ["#{attr_type_ns_class.uri}:#{rule.name}"]
1298
1374
  end
@@ -1309,7 +1385,7 @@ effective_register = lutaml_register)
1309
1385
  if attr_type_class
1310
1386
  attr_type_ns_class = if attr_type_class.is_a?(Class) &&
1311
1387
  attr_type_class.include?(::Lutaml::Model::Serialize)
1312
- attr_type_class.mappings_for(:xml)&.namespace_class
1388
+ type_namespace_class_of(attr_type_class)
1313
1389
  end
1314
1390
  if attr_type_ns_class
1315
1391
  return ["#{attr_type_ns_class.uri}:#{rule.name}"]
@@ -29,8 +29,17 @@ module Lutaml
29
29
  # functions of the element and the name; ISO-13849-shaped
30
30
  # documents repeat the same resolutions per rule application, so
31
31
  # the computed answer is memoized per element for the parse.
32
- def rule_name_resolution
33
- @rule_name_resolution ||= {}
32
+ def rule_name_resolution(element)
33
+ @rule_name_resolution ||= {}.compare_by_identity
34
+ @rule_name_resolution[element] ||= {}
35
+ end
36
+
37
+ # Lenient local-name fallback matches, per (element, flexible_local):
38
+ # spelling -> matched attribute VALUE. Same purity argument as
39
+ # rule_name_resolution — elements are parse-frozen.
40
+ def lenient_local_matches(element, flexible_local)
41
+ @lenient_local_matches ||= {}.compare_by_identity
42
+ (@lenient_local_matches[element] ||= {})[flexible_local] ||= {}
34
43
  end
35
44
 
36
45
  # Local-name -> [attributes] index over one element's attributes,
@@ -42,12 +42,31 @@ register_id, register)
42
42
  rule.attribute_type < Lutaml::Model::Serialize
43
43
  end
44
44
 
45
- if is_nested_model
46
- create_nested_model_element(rule, value, options, register,
47
- union: union)
48
- else
49
- create_simple_value_element(rule, value, options, model_class,
50
- register_id)
45
+ element = if is_nested_model
46
+ create_nested_model_element(rule, value, options,
47
+ register, union: union)
48
+ else
49
+ create_simple_value_element(rule, value, options,
50
+ model_class, register_id)
51
+ end
52
+ apply_when_attribute(element, rule)
53
+ element
54
+ end
55
+
56
+ # lutaml-model#88: re-emit the discriminator attributes on the
57
+ # element created for this rule, unless the value's own mapping
58
+ # already wrote them.
59
+ def apply_when_attribute(element, rule)
60
+ pairs = rule.option(:when_attribute)
61
+ return if pairs.nil? || pairs.empty?
62
+ return if element.nil?
63
+
64
+ pairs.each do |name, expected|
65
+ next if element.attributes.any? { |a| a.name == name.to_s }
66
+
67
+ element.attributes << ::Lutaml::Xml::DataModel::XmlAttribute.new(
68
+ name.to_s, expected.to_s
69
+ )
51
70
  end
52
71
  end
53
72
 
@@ -337,6 +337,7 @@ register_id, register, attr_name, custom_methods_value)
337
337
  value_map: value_map,
338
338
  custom_methods: custom_methods_value,
339
339
  polymorphic: mapping_rule.polymorphic,
340
+ when_attribute: mapping_rule.when_attribute,
340
341
  form: mapping_rule.form,
341
342
  delegate_from: delegate_target,
342
343
  )
@@ -417,6 +418,7 @@ register_id, register, custom_methods_value)
417
418
  value_map: value_map,
418
419
  custom_methods: custom_methods_value,
419
420
  polymorphic: mapping_rule.polymorphic,
421
+ when_attribute: mapping_rule.when_attribute,
420
422
  form: mapping_rule.form,
421
423
  alias_names: alias_names,
422
424
  )
@@ -137,26 +137,36 @@ RSpec.describe Lutaml::KeyValue::Transformation::RuleCompiler do
137
137
  end
138
138
 
139
139
  describe "#valid_mapping?" do
140
- let(:rule) { double("CompiledRule", attribute_name: :name) }
140
+ def compiled_rule(serialize: true)
141
+ Lutaml::Model::CompiledRule.new(
142
+ attribute_name: :name,
143
+ serialized_name: "name",
144
+ serialize: serialize,
145
+ )
146
+ end
141
147
 
142
148
  it "returns true when no only/except options" do
143
- expect(compiler.valid_mapping?(rule, {})).to be true
149
+ expect(compiler.valid_mapping?(compiled_rule, {})).to be true
144
150
  end
145
151
 
146
152
  it "returns true when attribute is in only list" do
147
- expect(compiler.valid_mapping?(rule, { only: [:name] })).to be true
153
+ expect(compiler.valid_mapping?(compiled_rule, { only: [:name] })).to be true
148
154
  end
149
155
 
150
156
  it "returns false when attribute is not in only list" do
151
- expect(compiler.valid_mapping?(rule, { only: [:age] })).to be false
157
+ expect(compiler.valid_mapping?(compiled_rule, { only: [:age] })).to be false
152
158
  end
153
159
 
154
160
  it "returns true when attribute is not in except list" do
155
- expect(compiler.valid_mapping?(rule, { except: [:age] })).to be true
161
+ expect(compiler.valid_mapping?(compiled_rule, { except: [:age] })).to be true
156
162
  end
157
163
 
158
164
  it "returns false when attribute is in except list" do
159
- expect(compiler.valid_mapping?(rule, { except: [:name] })).to be false
165
+ expect(compiler.valid_mapping?(compiled_rule, { except: [:name] })).to be false
166
+ end
167
+
168
+ it "returns false for a hydrate-only rule (serialize: false)" do
169
+ expect(compiler.valid_mapping?(compiled_rule(serialize: false), {})).to be false
160
170
  end
161
171
  end
162
172
 
@@ -283,7 +283,10 @@ RSpec.describe Lutaml::KeyValue::Transformation do
283
283
 
284
284
  expect(transformation).to be_frozen
285
285
 
286
- rule = instance_double(Lutaml::Model::CompiledRule, attribute_name: :name)
286
+ rule = Lutaml::Model::CompiledRule.new(
287
+ attribute_name: :name,
288
+ serialized_name: "name",
289
+ )
287
290
  expect do
288
291
  transformation.send(:valid_mapping?, rule, {})
289
292
  end.not_to raise_error
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#815: standalone `SomeModel.new.to_xml` crashed on 0.8.42 when
6
+ # the model's attributes are merged through register records whose imports
7
+ # clear @merged_attributes_cache re-entrantly from inside ensure_imports! —
8
+ # the memo store was materialized before ensure_imports! and the write after
9
+ # it hit nil[].
10
+ RSpec.describe "attributes memo across import-triggered cache clears" do
11
+ let(:namespace_class) do
12
+ Class.new(Lutaml::Xml::W3c::XmlNamespace) do
13
+ uri "https://example.com/ns815"
14
+ end
15
+ end
16
+
17
+ let(:register) do
18
+ Lutaml::Model::Register.new(:attrs815, fallback: [:default]).tap do |reg|
19
+ reg.bind_namespace(namespace_class)
20
+ end
21
+ end
22
+
23
+ let(:base_model) do
24
+ Class.new(Lutaml::Model::Serializable) do
25
+ attribute :shared, :string
26
+
27
+ xml do
28
+ element "Base815"
29
+ namespace namespace_class
30
+ map_element "shared", to: :shared
31
+ end
32
+ end
33
+ end
34
+
35
+ let(:consumer_model) do
36
+ Class.new(Lutaml::Model::Serializable) do
37
+ attribute :own, :string
38
+
39
+ xml do
40
+ element "Consumer815"
41
+ namespace namespace_class
42
+ map_element "own", to: :own
43
+ end
44
+ end
45
+ end
46
+
47
+ before do
48
+ stub_const("Attrs815::NamespaceClass", namespace_class)
49
+ Lutaml::Model::GlobalContext.reset!
50
+ Lutaml::Model::GlobalRegister.instance.register(register)
51
+ # Populate @register_records through the import the parse path uses.
52
+ base_model.import_model_attributes(consumer_model, register.id)
53
+ end
54
+
55
+ after do
56
+ Lutaml::Model::GlobalContext.reset!
57
+ end
58
+
59
+ it "serializes a register-bound model as its own document root" do
60
+ instance = base_model.new(shared: "x")
61
+ expect(instance.to_xml).to include("<shared>x</shared>")
62
+ end
63
+
64
+ it "merges register attributes after an import-triggered cache clear" do
65
+ instance = consumer_model.new(own: "y")
66
+ expect(instance.to_xml).to include("<own>y</own>")
67
+ end
68
+
69
+ it "keeps answering attributes() from the memo after imports" do
70
+ first = consumer_model.attributes(:attrs815)
71
+ expect(consumer_model.attributes(:attrs815)).to equal(first)
72
+ end
73
+
74
+ it "survives a re-entrant cache clear from inside ensure_imports!" do
75
+ # uniword's register-bound models (#815): choice/restrict resolution
76
+ # runs clear_cache while ensure_imports! is still on the stack, which
77
+ # nils @merged_attributes_cache after the reader materialized it.
78
+ reentrant = Class.new(base_model) do
79
+ def self.finalized? = true
80
+
81
+ def self.ensure_format_mapping_imports!(_register = nil)
82
+ clear_cache
83
+ super
84
+ end
85
+ end
86
+
87
+ expect { reentrant.attributes(:attrs815) }.not_to raise_error
88
+ expect(reentrant.attributes(:attrs815)[:shared].name).to eq(:shared)
89
+ end
90
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "../../../lib/lutaml/model"
5
+
6
+ module HydrateOnlyMapping
7
+ class Message < Lutaml::Model::Serializable
8
+ attribute :body, :string
9
+ attribute :source_offset, :integer
10
+
11
+ key_value do
12
+ map "body", to: :body
13
+ map "source_offset", to: :source_offset, serialize: false
14
+ end
15
+ end
16
+ end
17
+
18
+ RSpec.describe "hydrate-only mappings (serialize: false)" do
19
+ let(:input) { { "body" => "hello", "source_offset" => 42 } }
20
+
21
+ it "hydrates the attribute from input" do
22
+ message = HydrateOnlyMapping::Message.from_hash(input)
23
+
24
+ expect(message.body).to eq("hello")
25
+ expect(message.source_offset).to eq(42)
26
+ end
27
+
28
+ it "omits the attribute from serialized output" do
29
+ message = HydrateOnlyMapping::Message.from_hash(input)
30
+
31
+ expect(message.to_hash).to eq("body" => "hello")
32
+ end
33
+
34
+ it "round-trips through JSON without the hidden key" do
35
+ message = HydrateOnlyMapping::Message.from_json(input.to_json)
36
+
37
+ expect(JSON.parse(message.to_json)).to eq("body" => "hello")
38
+ end
39
+
40
+ it "keeps the mapping through deep-copied mappings" do
41
+ mapping = HydrateOnlyMapping::Message.mappings_for(:hash)
42
+ rule = mapping.mappings.find { |r| r.to == :source_offset }
43
+
44
+ expect(mapping.deep_dup.mappings.find { |r| r.to == :source_offset }
45
+ .serialize?).to eq(rule.serialize?)
46
+ end
47
+ end