lutaml-model 0.8.133 → 0.8.135

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: 7ea94bb734406bacfb047edb9c580a3f224f19c497b4f506440da60d381cd1bf
4
- data.tar.gz: 96f7c71f51a0ec8b863eec77b894ec611ff3a4e8b59354ec9b477e6470258852
3
+ metadata.gz: 51feb7853a13baba31f30840887bffdaa44dd44e7fe29c31e8e5fe1657847db7
4
+ data.tar.gz: 1464e32175a9ad90844529f27703040ee6e363184ce86c6edb7d6bcd560d287a
5
5
  SHA512:
6
- metadata.gz: 4e61d4a02ebb21a794de64a0301a3489b045b8562ce8e1a8f3522b227bea1632882e7e09d1ce6fe18352d41c8fe62d2a5d9652b315dda76a220cbd30fe15f3cf
7
- data.tar.gz: 60c206b080775425586980a1f3b484db2280be9e9b73bc365237210f64c90fc3faa80f1f9e9a42e94259a9a83ba1edab1ceecc25bb804147ecd3e31ead97c15d
6
+ metadata.gz: 5da32b5d0a99d97d60db8b7a0936cd1ad550779246397446d08fdc39082b5e6b847f9bf9070ea7c424df2597a4dc9ee683256b127f8425e25c736be318646d66
7
+ data.tar.gz: 50614e126790cd21f7a377d6dd777125c1ea8d90275323bb213607e06a747620758323b42c75a1b73205adc9396663ce30eda8fb2f6169bc2025ca3e48812548
@@ -446,6 +446,60 @@ Model.to_json # Uses KeyValue::Transformation → KeyValueElement → Adapter
446
446
  Model.to_json # Uses Hash transformation → Adapter
447
447
  ----
448
448
 
449
+ == The custom `to:` handler contract
450
+
451
+ A mapping rule that declares `with: { to: :method_name }` receives the
452
+ output document as its second argument and mutates it in place
453
+ (mutation-only — the handler's return value is ignored, lutaml-model
454
+ #892). Which object the handler receives depends on the serialization
455
+ lane:
456
+
457
+ * The transformation lane — the default path — hands the handler a
458
+ scratch `Lutaml::KeyValue::DataModel::Element` keyed with the rule's
459
+ serialized name. After the handler runs, only the element's payload
460
+ (the merged children, or the direct value hash) is merged into the
461
+ output.
462
+ * The legacy hash lane (used when no compiled transformation is
463
+ available, e.g. cyclic model graphs) hands the handler the output
464
+ `Hash` itself.
465
+
466
+ === The portable subset
467
+
468
+ Write handlers against the hash-like subset — it behaves identically on
469
+ both lanes:
470
+
471
+ [source,ruby]
472
+ ----
473
+ key_value do
474
+ map "locality", to: :type, with: { to: :locality_to_yaml }
475
+ end
476
+
477
+ def locality_to_yaml(model, doc)
478
+ doc["locality"] = {}
479
+ doc["locality"]["type"] = model.locality.type
480
+ doc["locality"]["reference_from"] = model.locality.reference_from
481
+ end
482
+ ----
483
+
484
+ On the transformation lane, `doc["locality"]` reads through to the
485
+ child's own value object, so chained writes mutate it in place; on the
486
+ legacy lane the same lines are plain Hash writes.
487
+
488
+ === Element-only surface
489
+
490
+ `doc.value =`, `doc.add_child`, and reads of `doc.value` exist only on
491
+ the transformation lane's Element. A handler that uses them breaks if
492
+ the legacy lane ever serves the model (cyclic graphs, mapping
493
+ overrides). Prefer the hash-like subset unless the handler is known to
494
+ run only on the transformation lane.
495
+
496
+ === Merge semantics
497
+
498
+ After the handler runs, the document's payload merges into the output
499
+ only when it is a `Hash` — an untouched document contributes nothing,
500
+ and scalar or array payloads are dropped (matching the pre-0.8.74
501
+ behavior).
502
+
449
503
  == Serialization Flow
450
504
 
451
505
  === Complete Data Flow
@@ -98,7 +98,26 @@ module Lutaml
98
98
  return [member, casted] unless casted == :no_match
99
99
  end
100
100
 
101
- lenient_model_member(value, members, format, register)
101
+ widest_model_member(value, members, format, register) ||
102
+ lenient_model_member(value, members, format, register)
103
+ end
104
+
105
+ # No member maps every key, so the hash carries keys no rule claims
106
+ # (extension keywords, forward-compatible vocabularies): the model
107
+ # member mapping the most of its keys, the earlier one on a tie, as
108
+ # the keys no rule claims are ignored outside a union (#987).
109
+ def widest_model_member(value, members, format, register)
110
+ keys = input_keys(value, format)
111
+ return nil if keys.nil? || keys.empty?
112
+
113
+ counts = members.select { |member| model_member?(member) }.to_h do |member|
114
+ field_names = member_field_names(member, format, register)
115
+ [member, keys.count { |key| field_names.include?(key) }]
116
+ end
117
+ member, count = counts.max_by.with_index { |(_, mapped), index| [mapped, -index] }
118
+ return nil if member.nil? || count.zero?
119
+
120
+ [member, build_model(member, value, format, register)]
102
121
  end
103
122
 
104
123
  # With exactly one model member, a Hash that also carries unmapped
@@ -170,14 +189,12 @@ module Lutaml
170
189
  member.is_a?(::Class) && member.include?(Serialize)
171
190
  end
172
191
 
173
- # Structural selection: at least one input key maps to one of the
174
- # member's mapped fields. Keys no rule claims are ignored exactly
175
- # as they are when the same hash reaches the model outside a
176
- # union — extension keys (x-** vendor keywords, forward-compatible
177
- # vocabularies) must not defeat dispatch (#987). Once selected,
178
- # field values cast leniently via the member's normal path. An
179
- # existing member instance conforms as-is, keeping conformance
180
- # idempotent — values can re-enter casting (e.g. a value
192
+ # Structural (key-coverage) selection: every input key maps to one of
193
+ # the member's mapped fields, which is what tells members sharing a
194
+ # key apart. A hash no member covers falls to widest_model_member.
195
+ # Once selected, field values cast leniently via the member's normal
196
+ # path. An existing member instance conforms as-is, keeping
197
+ # conformance idempotent — values can re-enter casting (e.g. a value
181
198
  # transformer builds the member before the cast runs).
182
199
  def conform_model(member, value, format, register)
183
200
  return value if value.is_a?(member)
@@ -186,13 +203,17 @@ module Lutaml
186
203
  return :no_match if keys.nil? || keys.empty?
187
204
 
188
205
  field_names = member_field_names(member, format, register)
189
- return :no_match unless keys.any? do |key|
206
+ return :no_match unless keys.all? do |key|
190
207
  field_names.include?(key)
191
208
  end
192
209
 
193
- # Plain-Ruby assignment (format nil) builds straight from the
194
- # attribute hash, mirroring how non-union model attributes accept
195
- # `Model.new(child: { ... })`; a format builds via the mappings.
210
+ build_model(member, value, format, register)
211
+ end
212
+
213
+ # Plain-Ruby assignment (format nil) builds straight from the
214
+ # attribute hash, mirroring how non-union model attributes accept
215
+ # `Model.new(child: { ... })`; a format builds via the mappings.
216
+ def build_model(member, value, format, register)
196
217
  return member.new(value) if format.nil?
197
218
 
198
219
  member.apply_mappings(value, format, register: register)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.133"
5
+ VERSION = "0.8.135"
6
6
  end
7
7
  end
@@ -19,12 +19,12 @@ module Lutaml
19
19
  namespace Lutaml::Xml::Schema::XsdNamespace
20
20
 
21
21
  map_attribute :id, to: :id
22
- map_attribute :schema_location, to: :schema_path
22
+ map_attribute "schemaLocation", to: :schema_path
23
23
  map_element :group, to: :group
24
24
  map_element :annotation, to: :annotation
25
- map_element :simpleType, to: :simpleType
26
- map_element :complexType, to: :complexType
27
- map_element :attributeGroup, to: :attributeGroup
25
+ map_element :simpleType, to: :simple_type
26
+ map_element :complexType, to: :complex_type
27
+ map_element :attributeGroup, to: :attribute_group
28
28
  end
29
29
 
30
30
  Lutaml::Xml::Schema::Xsd.register_model(self, :redefine)
@@ -710,6 +710,76 @@ RSpec.describe "Union-typed attributes (issue #190)" do
710
710
  end
711
711
  end
712
712
 
713
+ describe "model members that share a key" do
714
+ # Two members both map "source"; only their other keys tell them
715
+ # apart. The member mapping every key wins over an earlier one that
716
+ # maps only the shared key, as before #987; with an extension key
717
+ # neither maps every key, so the member mapping the most keys wins.
718
+ let(:report) do
719
+ Class.new(Lutaml::Model::Serializable) do
720
+ attribute :source, :string
721
+ attribute :issues, :string, collection: true
722
+ key_value do
723
+ map "source", to: :source
724
+ map "issues", to: :issues
725
+ end
726
+ end
727
+ end
728
+
729
+ let(:conversion) do
730
+ Class.new(Lutaml::Model::Serializable) do
731
+ attribute :source, :string
732
+ attribute :target, :string
733
+ key_value do
734
+ map "source", to: :source
735
+ map "target", to: :target
736
+ end
737
+ end
738
+ end
739
+
740
+ let(:holder) do
741
+ members = [report, conversion]
742
+ Class.new(Lutaml::Model::Serializable) do
743
+ attribute :result, members
744
+ key_value { map "result", to: :result }
745
+ end
746
+ end
747
+
748
+ before do
749
+ stub_const("SharedKeyReport", report)
750
+ stub_const("SharedKeyConversion", conversion)
751
+ stub_const("SharedKeyHolder", holder)
752
+ end
753
+
754
+ it "selects the later member whose fields cover every key" do
755
+ parsed = holder.from_json('{"result":{"source":"a.emf","target":"svg"}}')
756
+
757
+ expect(parsed.result).to be_a(conversion)
758
+ expect(parsed.result.target).to eq("svg")
759
+ end
760
+
761
+ it "keeps the earlier member when it covers every key" do
762
+ parsed = holder.from_json('{"result":{"source":"a.emf","issues":["m"]}}')
763
+
764
+ expect(parsed.result).to be_a(report)
765
+ expect(parsed.result.issues).to eq(["m"])
766
+ end
767
+
768
+ it "selects the member mapping the most keys when an extension key is present" do
769
+ parsed = holder.from_json(
770
+ '{"result":{"source":"a.emf","target":"svg","x-ext":1}}',
771
+ )
772
+
773
+ expect(parsed.result).to be_a(conversion)
774
+ end
775
+
776
+ it "keeps declared order when members map equally many keys" do
777
+ parsed = holder.from_json('{"result":{"source":"a.emf","x-ext":1}}')
778
+
779
+ expect(parsed.result).to be_a(report)
780
+ end
781
+ end
782
+
713
783
  describe "value transformers (cast idempotence)" do
714
784
  it "keeps a model member built by transform: before the cast runs" do
715
785
  reading = UnionAttributeSpec::TransformedReading.from_yaml(<<~YAML)
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/xml"
5
+ require "lutaml/xml/schema/xsd"
6
+
7
+ # The XSD schema-object classes carry hand-written XML mappings; a `to:`
8
+ # that does not name a declared attribute compiles fine and then crashes
9
+ # at parse time with "Attribute '...' not found" — for ANY document that
10
+ # contains the class, because the rule loop validates every mapping rule
11
+ # against the instance, not only the children present (#1025: redefine
12
+ # shipped to: :simpleType / :complexType / :attributeGroup against
13
+ # simple_type / complex_type / attribute_group). This sweep pins every
14
+ # registered model's element and attribute rules to declared attributes
15
+ # so the whole typo family fails here, at spec time.
16
+ RSpec.describe "XSD schema model mapping integrity" do
17
+ it "maps every element rule of every registered model to a declared attribute" do
18
+ broken = []
19
+
20
+ Lutaml::Xml::Schema::Xsd.register.models.each_value do |klass|
21
+ next unless klass.is_a?(Class) && klass.include?(Lutaml::Model::Serialize)
22
+
23
+ mapping = klass.mappings_for(:xml)
24
+ mapping.mapping_elements_hash.each_value do |rule_or_array|
25
+ Array(rule_or_array).each do |rule|
26
+ unless klass.attributes.key?(rule.to)
27
+ broken << "#{klass.name}: map_element #{rule.name.inspect} -> " \
28
+ "#{rule.to.inspect}"
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ expect(broken).to be_empty, <<~MSG
35
+ mapping rules point at undeclared attributes:
36
+ #{broken.join("\n")}
37
+ MSG
38
+ end
39
+
40
+ it "maps every attribute rule of every registered model to a declared attribute" do
41
+ broken = []
42
+
43
+ Lutaml::Xml::Schema::Xsd.register.models.each_value do |klass|
44
+ next unless klass.is_a?(Class) && klass.include?(Lutaml::Model::Serialize)
45
+
46
+ mapping = klass.mappings_for(:xml)
47
+ mapping.mapping_attributes_hash.each_value do |rule_or_array|
48
+ Array(rule_or_array).each do |rule|
49
+ unless klass.attributes.key?(rule.to)
50
+ broken << "#{klass.name}: map_attribute #{rule.name.inspect} -> " \
51
+ "#{rule.to.inspect}"
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ expect(broken).to be_empty, <<~MSG
58
+ mapping rules point at undeclared attributes:
59
+ #{broken.join("\n")}
60
+ MSG
61
+ end
62
+
63
+ # #1025: the regression that motivated the sweep — a redefine with a
64
+ # group child parses, and each redefined member kind lands on its
65
+ # declared attribute.
66
+ it "parses xs:redefine with group, simpleType, complexType and attributeGroup" do
67
+ doc = Lutaml::Xml::Schema::Xsd.parse(<<~XSD)
68
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
69
+ <xsd:redefine schemaLocation="base.xsd">
70
+ <xsd:group name="g">
71
+ <xsd:choice><xsd:element name="e1"/></xsd:choice>
72
+ </xsd:group>
73
+ <xsd:simpleType name="st">
74
+ <xsd:restriction base="xsd:string"/>
75
+ </xsd:simpleType>
76
+ <xsd:complexType name="ct">
77
+ <xsd:sequence><xsd:element name="x"/></xsd:sequence>
78
+ </xsd:complexType>
79
+ <xsd:attributeGroup name="ag"/>
80
+ </xsd:redefine>
81
+ </xsd:schema>
82
+ XSD
83
+
84
+ redefined = doc.redefine.first
85
+
86
+ expect(redefined.group.name).to eq("g")
87
+ expect(redefined.simple_type.name).to eq("st")
88
+ expect(redefined.complex_type.name).to eq("ct")
89
+ expect(redefined.attribute_group.name).to eq("ag")
90
+ expect(redefined.schema_path).to eq("base.xsd")
91
+ end
92
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.133
4
+ version: 0.8.135
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -2056,6 +2056,7 @@ files:
2056
2056
  - spec/lutaml/xml/schema/xsd/liquid_methods/schema_spec.rb
2057
2057
  - spec/lutaml/xml/schema/xsd/liquid_methods/simple_content_spec.rb
2058
2058
  - spec/lutaml/xml/schema/xsd/list_simple_type_spec.rb
2059
+ - spec/lutaml/xml/schema/xsd/mapping_integrity_spec.rb
2059
2060
  - spec/lutaml/xml/schema/xsd/namespace_uri_remapping_spec.rb
2060
2061
  - spec/lutaml/xml/schema/xsd/schema_helper_methods_spec.rb
2061
2062
  - spec/lutaml/xml/schema/xsd/schema_mapping_spec.rb