lutaml-model 0.8.92 → 0.8.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19d1cc9a53554486e1dda21b67751062851d1990b9878aac1dcd6ccd2542d28e
4
- data.tar.gz: 459cecd7cbdd127bb65a52cd6058d027e5139f2926e46d408bea5a28b13f2e7f
3
+ metadata.gz: '082f3275306fcd89a94f69f68b53d38b0c4c644de743dff261b6bc5166b12ec7'
4
+ data.tar.gz: 968f9d94e179035695be49649bd9c0b0c2e7e83192661ba3f6d95bbd5803a813
5
5
  SHA512:
6
- metadata.gz: 92637d4ab1efbd63d746983b4e359f2d49c00469e94bb249a050f36f1b80f15b18ae705245fdd8ab563d854e814fea54ce99993f64dbc17beb9c87555f7b3a95
7
- data.tar.gz: 54412f06f6f1bed27f74a85495b414cfc6da3274ab03214220c27edd344aff8cd58f70874fa317fb61b9e8523a04725d69152dfde490b3aa61a2d75540a161ed
6
+ metadata.gz: 4f06573be9b9da0d12a561522a63d2d14b771a3ff7357fdb92c8d06213fa997efe19470f0d6b6ab39d9c672038715cc6efa1d607cb77dce5487e2134b56335a1
7
+ data.tar.gz: 275f2d2c74931c84024278dbc5bddcfe18cc267990c0516080605ce1fd2170c5513efcdd141ffbb72b9967cb91fd7ed74b338936a0e1a793aeba2411f8405d09
@@ -62,6 +62,28 @@ module Lutaml
62
62
  #{lines}
63
63
  end
64
64
  RUBY
65
+
66
+ # lutaml-model#922: raw readers for the deserializer's
67
+ # unmatched-rule handling. A custom writer that assigns a
68
+ # *different* attribute mid-parse (relaton's ICS Isoics
69
+ # fallback; any derived-value pattern) is invisible to the
70
+ # value_set tracker -- public setters don't mark it -- so the
71
+ # tracked "still default" state lies. The deserializer needs
72
+ # the raw stored state to know whether an attribute was
73
+ # writer-populated: an attribute still holding its
74
+ # initialized marker (sentinel / lazy collection) is unset;
75
+ # anything else was written since init and must never be
76
+ # overwritten by an unmatched rule's default assignment.
77
+ raw_lines = attrs.map do |name, _attr|
78
+ <<~RUBY
79
+ def __lutaml_raw_#{name}
80
+ @#{name}
81
+ end
82
+ RUBY
83
+ end.join
84
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
85
+ #{raw_lines}
86
+ RUBY
65
87
  end
66
88
 
67
89
  # Historical getter shape for punctuation-named attributes and
@@ -110,6 +132,28 @@ module Lutaml
110
132
  name = attr.name
111
133
  register_id = extract_register_id(register)
112
134
 
135
+ # lutaml-model#922: raw-state reader for the deserializer's
136
+ # unmatched-rule handling. A custom writer that assigns a
137
+ # *different* attribute mid-parse (relaton's ICS Isoics
138
+ # fallback; any derived-value pattern) is invisible to the
139
+ # value_set tracker -- public setters don't mark it -- so the
140
+ # tracked "still default" state lies. The deserializer needs
141
+ # the raw stored state to know whether an attribute was
142
+ # writer-populated: still holding the initialized marker
143
+ # (sentinel / lazy collection) means unset; anything else was
144
+ # written since init and must never be overwritten by an
145
+ # unmatched rule's default assignment. Declared here (not in
146
+ # the state-defaults compilation) so late declarations get it
147
+ # too -- XMI's extension loader declares attributes after
148
+ # first parse.
149
+ if name.to_s.match?(PLAIN_NAME)
150
+ model.class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
151
+ def __lutaml_raw_#{name}
152
+ @#{name}
153
+ end
154
+ RUBY
155
+ end
156
+
113
157
  if attr.enum?
114
158
  add_enum_methods_to_model(
115
159
  model,
@@ -354,6 +398,11 @@ module Lutaml
354
398
  attr = Attribute.new(name, type, options)
355
399
  @attributes[name] = attr
356
400
  @merged_attributes_cache = nil
401
+ # Attributes can be declared after the model has been
402
+ # instantiated (forward references, post-hoc declarations);
403
+ # the interned writer table from the first instantiation must
404
+ # not outlive the attribute set it was built from.
405
+ @instantiate_writers = nil
357
406
  invalidate_state_defaults!
358
407
  define_attribute_methods(attr)
359
408
 
@@ -392,6 +392,22 @@ module Lutaml
392
392
  # set (nil when no default), never left as the
393
393
  # uninitialized sentinel — readers and formatters touch
394
394
  # absent attributes freely.
395
+ #
396
+ # lutaml-model#922: unless a custom writer already
397
+ # populated it. An earlier kwarg's writer may have
398
+ # assigned this attribute as a side effect (relaton's ICS
399
+ # populates <text> from the Isoics dataset inside code=);
400
+ # the raw state is the truth — still at its initialized
401
+ # marker (sentinel / lazy collection) means unset, and
402
+ # only then is the default seeded. The writer's own
403
+ # value_set_for (inside the compiled writer) already
404
+ # marked it set, so it renders.
405
+ raw = instance.public_send(:"__lutaml_raw_#{name}")
406
+ unset = raw.nil? ||
407
+ raw.equal?(Lutaml::Model::UninitializedClass.instance) ||
408
+ raw.equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION)
409
+ next unless unset
410
+
395
411
  default = attr.default_value(register_id, instance)
396
412
  value = if Lutaml::Model::Utils.uninitialized?(default)
397
413
  nil
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.92"
5
+ VERSION = "0.8.94"
6
6
  end
7
7
  end
@@ -345,12 +345,11 @@ module Lutaml
345
345
  mappings.each do |rule|
346
346
  record = records&.[](rule)
347
347
 
348
- # #903: replay-or-drop unmatched named element rules before
349
- # any per-rule machinery (see replay_unmatched_rule).
350
- if replay_unmatched_rule(child_names_set, record, rule,
351
- instance, defaults_used, xml_mapping)
352
- next
353
- end
348
+ # #903/#922: replay-or-drop unmatched rules; never clobber
349
+ # a mid-parse custom-writer assignment.
350
+ next if replay_unmatched_rule(child_names_set, record, rule,
351
+ instance, defaults_used,
352
+ xml_mapping)
354
353
 
355
354
  rule_to = rule.to
356
355
 
@@ -436,11 +435,19 @@ module Lutaml
436
435
  # NOTE: do not "read the instance" here (to_value_for) —
437
436
  # the compiled getters materialize lazy collections on
438
437
  # first read, which tripled parse allocations when tried.
438
+ if writer_populated?(record, instance)
439
+ instance.value_set_for(rule_to)
440
+ next
441
+ end
439
442
  if instance.using_default?(rule_to) || rule.render_default
440
443
  defaults_used << rule_to
441
444
  attr&.default(effective_register) || rule.to_value_for(instance)
442
445
  else
443
- ::Lutaml::Model::UninitializedClass.instance
446
+ # The attribute already holds an explicit value --
447
+ # assigned by a matched rule or by a custom writer
448
+ # mid-parse (lutaml-model#922). An unmatched rule
449
+ # must not overwrite it with nil.
450
+ next
444
451
  end
445
452
  else
446
453
  raise "ELSE-BODY" if ENV["CATCHDBG"] && rule.name.nil?
@@ -857,7 +864,7 @@ _effective_register)
857
864
 
858
865
  Record = Struct.new(:attr, :derived, :group_skip, :valid,
859
866
  :unmatched_mode, :replay_writer,
860
- :unmatched_names)
867
+ :unmatched_names, :raw_reader)
861
868
 
862
869
  def compiled_rule_records(model_class, register, grouped_plain_rules)
863
870
  return {}.compare_by_identity unless model_class.include?(Lutaml::Model::Serialize)
@@ -946,6 +953,15 @@ _effective_register)
946
953
  unmatched_mode,
947
954
  replay_writer,
948
955
  unmatched_names,
956
+ # lutaml-model#922: raw-state reader for the attribute,
957
+ # when the compiled init seeds it (plain names -- the same
958
+ # set compile_state_defaults! writes). Delegated rules name
959
+ # an attribute on the delegate object, not on this
960
+ # instance, so they get no raw reader.
961
+ if attr && rule.delegate.nil? &&
962
+ rule.to.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
963
+ :"__lutaml_raw_#{rule.to}"
964
+ end,
949
965
  )
950
966
  end
951
967
  entry = Struct.new(:version, :records).new(version, records)
@@ -1630,6 +1646,24 @@ effective_register = lutaml_register)
1630
1646
  # (the visit ended at the omission gate or was a guarded next)
1631
1647
  # writes nothing; :write_nil replays the nil the default value
1632
1648
  # map produces, plus the flag churn. Returns true when handled.
1649
+ # lutaml-model#922: whether the attribute holds anything other
1650
+ # than its initialized marker (uninitialized sentinel / lazy
1651
+ # collection) — i.e. a custom writer populated it mid-parse.
1652
+ def writer_populated?(record, instance)
1653
+ return false unless record&.raw_reader
1654
+
1655
+ !raw_initialized?(record, instance)
1656
+ end
1657
+
1658
+ # True when the attribute still holds the value the compiled init
1659
+ # seeded (uninitialized sentinel / lazy collection) — i.e. nothing
1660
+ # has written it since.
1661
+ def raw_initialized?(record, instance)
1662
+ raw = instance.public_send(record.raw_reader)
1663
+ raw.equal?(::Lutaml::Model::UninitializedClass.instance) ||
1664
+ raw.equal?(::Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION)
1665
+ end
1666
+
1633
1667
  def replay_unmatched_rule(child_names_set, record, rule, instance,
1634
1668
  defaults_used, xml_mapping)
1635
1669
  return false unless child_names_set && record
@@ -1647,9 +1681,30 @@ effective_register = lutaml_register)
1647
1681
 
1648
1682
  return true if mode == :drop
1649
1683
 
1650
- defaults_used << rule.to
1684
+ # The visited path picks the default branch only while the
1685
+ # attribute still reads as default-valued; once another rule
1686
+ # or a custom writer set it mid-parse, the omission gate ends
1687
+ # the visit with no write. Mirror that: replay writes only
1688
+ # while unset, and drops otherwise (a custom writer may have
1689
+ # populated the attribute — relaton's ICS Isoics-fallback
1690
+ # pattern).
1691
+ rule_to = rule.to
1692
+ unless instance.using_default?(rule_to)
1693
+ return true
1694
+ end
1695
+
1696
+ # lutaml-model#922: a custom writer may have populated the
1697
+ # attribute mid-parse without going through the tracker (the
1698
+ # compiled setters are direct ivar writes), so the tracked
1699
+ # flag above can lie. The raw state is the truth: still at its
1700
+ # initialized marker (sentinel / lazy collection) means unset
1701
+ # -- the visited path wrote the default there; anything else
1702
+ # is a writer assignment this replay must not clobber.
1703
+ return true unless raw_initialized?(record, instance)
1704
+
1705
+ defaults_used << rule_to
1651
1706
  instance.public_send(record.replay_writer, nil)
1652
- instance.value_set_for(rule.to)
1707
+ instance.value_set_for(rule_to)
1653
1708
  true
1654
1709
  end
1655
1710
 
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Attributes can be declared after the model has been instantiated
6
+ # (forward references, post-hoc declarations). The interned writer
7
+ # table built during the first instantiation must not outlive the
8
+ # attribute set it was built from.
9
+ RSpec.describe "attributes declared after first instantiation" do
10
+ it "initializes attributes added after an earlier instantiation" do
11
+ klass = Class.new(Lutaml::Model::Serializable) do
12
+ attribute :name, :string
13
+ end
14
+
15
+ klass.new(name: "first")
16
+
17
+ klass.attribute :size, :string
18
+ instance = klass.new(name: "second", size: "L")
19
+
20
+ expect(instance.name).to eq("second")
21
+ expect(instance.size).to eq("L")
22
+ end
23
+
24
+ it "keeps earlier instances unaffected" do
25
+ klass = Class.new(Lutaml::Model::Serializable) do
26
+ attribute :name, :string
27
+ end
28
+
29
+ first = klass.new(name: "first")
30
+ klass.attribute :size, :string
31
+
32
+ expect(first.name).to eq("first")
33
+ expect(first.respond_to?(:size)).to be(true)
34
+ end
35
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ module WriterAssigned
6
+ class Plain < Lutaml::Model::Serializable
7
+ attribute :code, :string
8
+ attribute :text, :string
9
+
10
+ xml do
11
+ element "probe"
12
+ map_element "code", to: :code
13
+ map_element "text", to: :text
14
+ end
15
+
16
+ def code=(val)
17
+ super
18
+ self.text = "derived-#{val}" if val
19
+ end
20
+ end
21
+
22
+ class Defaulted < Lutaml::Model::Serializable
23
+ attribute :code, :string
24
+ attribute :text, :string, default: -> { "" }
25
+
26
+ xml do
27
+ element "probe"
28
+ map_element "code", to: :code
29
+ map_element "text", to: :text
30
+ end
31
+
32
+ def code=(val)
33
+ super
34
+ self.text = "derived-#{val}" if val
35
+ end
36
+ end
37
+ end
38
+
39
+ # lutaml-model#922: a custom writer that assigns a *different* attribute
40
+ # mid-parse (relaton's Relaton::Bib::ICS populates <text> from the Isoics
41
+ # dataset inside code=) must survive the deserializer's unmatched-rule
42
+ # handling: the default seeding in Initialization#instantiate and the
43
+ # unmatched-rule replay must never overwrite a value a writer assigned.
44
+ RSpec.describe "attributes assigned inside another attribute's writer" do
45
+ %i[Plain Defaulted].each do |klass_name|
46
+ klass = WriterAssigned.const_get(klass_name)
47
+
48
+ describe "with #{klass_name.to_s.downcase} text attribute" do
49
+ it "keeps the derived value through from_xml" do
50
+ instance = klass.from_xml("<probe><code>67.060</code></probe>")
51
+
52
+ expect(instance.text).to eq("derived-67.060")
53
+ end
54
+
55
+ it "renders the derived value on to_xml" do
56
+ instance = klass.from_xml("<probe><code>67.060</code></probe>")
57
+
58
+ expect(instance.to_xml).to include("<text>derived-67.060</text>")
59
+ end
60
+ end
61
+ end
62
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.92
4
+ version: 0.8.94
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-03 00:00:00.000000000 Z
11
+ date: 2026-10-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -1759,6 +1759,7 @@ files:
1759
1759
  - spec/lutaml/model/jsonl_spec.rb
1760
1760
  - spec/lutaml/model/key_value_data_model/key_value_element_spec.rb
1761
1761
  - spec/lutaml/model/key_value_mapping_spec.rb
1762
+ - spec/lutaml/model/late_attribute_declaration_spec.rb
1762
1763
  - spec/lutaml/model/lazy_collection_spec.rb
1763
1764
  - spec/lutaml/model/liquefiable_lazy_spec.rb
1764
1765
  - spec/lutaml/model/liquefiable_spec.rb
@@ -1872,6 +1873,7 @@ files:
1872
1873
  - spec/lutaml/model/value_policy_spec.rb
1873
1874
  - spec/lutaml/model/value_transformation_comprehensive_spec.rb
1874
1875
  - spec/lutaml/model/with_child_mapping_spec.rb
1876
+ - spec/lutaml/model/writer_assigned_attribute_spec.rb
1875
1877
  - spec/lutaml/model/xml_decoupling_spec.rb
1876
1878
  - spec/lutaml/model/xsd_form_default_patterns_spec.rb
1877
1879
  - spec/lutaml/model/xsd_patterns_spec.rb