lutaml-model 0.8.121 → 0.8.123

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: 6b4c137e1f669303a163bde341097dae39b9171b3f9c65b2a8dba97fff0ac6e5
4
- data.tar.gz: a9c13e332f7228b0d45c1a2b24ce9cbe032c7d3b9a06b8585b54c30bd78fcfdc
3
+ metadata.gz: 6d77a19ece999a4de97c03776b3f56bc31eb217fb14904362eef4c98b4623be6
4
+ data.tar.gz: b76ef3e5de721b3e94a02dbac605c61fe3ca44fe97388b8fca1ec4645c3ab494
5
5
  SHA512:
6
- metadata.gz: 51cad246d238f97af6becc7e9f90409d21152e60ff136658832e3440656a13f02b18693f8683605e0831738a2fb707490d137a2a47769121a2139aa845c927f6
7
- data.tar.gz: a7be0c13e87ac6d37a61044bdea091f7d49a21ad2bb75cd206424456c9d1a77a13ba895b9f2d5d7b6ae6c45fe64b71ae493cd4dab444486641ebbc7cc6294a08
6
+ metadata.gz: 3a03373b96d83e69500cbd2371557f5ced955e85964b3a92340e8bf3cb4da0b45ea3f83ed59fc6f47e410661c5a2e0ca0b9a5d2db6d890ce9d192ed35ccda2c1
7
+ data.tar.gz: 6675bdd426008a94d3e1367b584d3ce92e242b6d6e6e3a85c1e7afc544ab57cce7da847a7af53c9a3f11bd1bfb4f3b49cefb1032eb8157a5e2ddee32b4761545
@@ -187,6 +187,24 @@ module Lutaml
187
187
 
188
188
  alias map_element map
189
189
 
190
+ # Collects every document key no declared rule claims into the given
191
+ # attribute (#985): vendor extensions and forward-compatible keywords
192
+ # survive the round-trip instead of being dropped. Allowed alongside
193
+ # normal mappings; keys claimed by any rule are never collected, and
194
+ # on serialization the collected hash is merged after the mapped
195
+ # rules so mapped keys win.
196
+ def map_unmapped(to:)
197
+ @unmapped_attribute = to
198
+ end
199
+
200
+ def unmapped_attribute
201
+ @unmapped_attribute
202
+ end
203
+
204
+ # Writer used by deep_dup to carry the catch-all across mapping
205
+ # copies (registers, value-map specializations).
206
+ attr_writer :unmapped_attribute
207
+
190
208
  def map_all(
191
209
  to: nil,
192
210
  render_nil: false,
@@ -333,6 +351,7 @@ module Lutaml
333
351
  dup_instance.tap do |new_mapping|
334
352
  new_mapping.mappings = duplicate_mappings
335
353
  new_mapping.register_mappings = Lutaml::Model::Utils.deep_dup(@register_mappings)
354
+ new_mapping.unmapped_attribute = @unmapped_attribute
336
355
  end
337
356
  end
338
357
 
@@ -43,10 +43,56 @@ module Lutaml
43
43
  rules.each do |rule|
44
44
  process_mapping_rule(data, instance, format, rule, options, partition)
45
45
  end
46
+ collect_unmapped_keys(data, instance, mappings, rules)
46
47
 
47
48
  instance
48
49
  end
49
50
 
51
+ # #985: with `map_unmapped to:` declared, document keys no rule
52
+ # claims are collected verbatim into the target attribute, exactly
53
+ # as the model would have kept them outside a mapping.
54
+ def collect_unmapped_keys(data, instance, mappings, rules)
55
+ return unless data.is_a?(::Hash)
56
+
57
+ target = validated_unmapped_target(mappings)
58
+ return if target.nil?
59
+
60
+ claimed = {}
61
+ rules.each do |rule|
62
+ Array(rule.name).each { |name| claimed[name.to_s] = true } if rule.name
63
+ end
64
+
65
+ extras = nil
66
+ data.each do |key, value|
67
+ next if claimed[key.to_s]
68
+
69
+ (extras ||= {})[key] = value
70
+ end
71
+ return if extras.nil?
72
+
73
+ existing = instance.public_send(target)
74
+ merged = existing.is_a?(::Hash) ? existing.merge(extras) : extras
75
+ instance.public_send(:"#{target}=", merged)
76
+ end
77
+
78
+ # Memoized on the cached transform (one instance per model class
79
+ # and register): a typo'd map_unmapped target raises at first
80
+ # parse, naming the class and the target, instead of NoMethodError
81
+ # from inside the walk.
82
+ def validated_unmapped_target(mappings)
83
+ target = mappings.unmapped_attribute
84
+ return if target.nil?
85
+ return @lutaml_unmapped_target if @lutaml_unmapped_target == target
86
+
87
+ unless model_class.attributes(lutaml_register).key?(target)
88
+ raise ArgumentError,
89
+ "map_unmapped target :#{target} is not a declared " \
90
+ "attribute of #{model_class}"
91
+ end
92
+
93
+ @lutaml_unmapped_target = target
94
+ end
95
+
50
96
  def model_to_data(instance, format, options = {})
51
97
  # NEW ARCHITECTURE: Use KeyValue::Transformation if available
52
98
  # This provides symmetric OOP architecture with XmlDataModel
@@ -83,10 +129,23 @@ module Lutaml
83
129
  process_rule!(instance, rule, hash, format, mappings, options)
84
130
  end
85
131
  end
132
+ merge_unmapped_on_serialize(instance, mappings, hash)
86
133
 
87
134
  hash.keys == [""] ? hash[""] : hash
88
135
  end
89
136
 
137
+ # #985: re-emit the collected unknown keys after the mapped rules,
138
+ # so a mapped key colliding with a collected one wins. The
139
+ # attribute was declared in the mapping and validated at compile,
140
+ # so the accessor exists on every instance shape.
141
+ def merge_unmapped_on_serialize(instance, mappings, hash)
142
+ target = mappings.unmapped_attribute
143
+ return if target.nil?
144
+
145
+ extras = instance.public_send(target)
146
+ extras.each { |key, value| hash[key] ||= value } if extras.is_a?(::Hash)
147
+ end
148
+
90
149
  # ---- TODO.max-perf/37: group-then-instantiate fast path ----
91
150
 
92
151
  # [model class, format, register] -> rows or false (ineligible).
@@ -106,6 +165,21 @@ module Lutaml
106
165
  Concurrent::Map.new
107
166
  end
108
167
 
168
+ # [model class, register, format, rule, generation] -> KvRulePlan,
169
+ # or false for ineligible rules. Class-level: the interpretive
170
+ # walk re-derived the same eligibility chain for the same rule at
171
+ # every node (~12 predicate allocations x nodes per heroku.json
172
+ # parse). Same idempotent-write, generation-retirement pattern as
173
+ # KV_GROUP_PLANS; false (not nil) marks ineligible so a miss never
174
+ # rebuilds.
175
+ KV_RULE_PLANS = if Lutaml::Model.opal?
176
+ {}
177
+ else
178
+ Lutaml::Model::RuntimeCompatibility
179
+ .require_native("concurrent")
180
+ Concurrent::Map.new
181
+ end
182
+
109
183
  def self.kv_group_plan(model_class, format, register)
110
184
  # Context generation: specs (and apps) reset registers between
111
185
  # parses; a plan cached across a reset references dead attribute
@@ -138,6 +212,9 @@ module Lutaml
138
212
 
139
213
  mapping = model_class.mappings_for(format, register)
140
214
  return false if mapping.nil?
215
+ # The catch-all collect runs on the interpretive walk only —
216
+ # the compiled hydration must not skip unknown keys silently.
217
+ return false if mapping.unmapped_attribute
141
218
 
142
219
  attrs = model_class.attributes(register)
143
220
  rows = nil
@@ -732,9 +809,14 @@ partition = nil)
732
809
  end
733
810
 
734
811
  def kv_rule_plan(format, rule, attr)
735
- @kv_rule_plans ||= {}
736
- @kv_rule_plans[[format, rule]] ||= build_kv_rule_plan(format, rule,
737
- attr)
812
+ key = [model_class, lutaml_register, format, rule,
813
+ Lutaml::Model::GlobalContext.context_generation]
814
+ cached = KV_RULE_PLANS[key]
815
+ return cached == false ? nil : cached unless cached.nil?
816
+
817
+ plan = build_kv_rule_plan(format, rule, attr)
818
+ KV_RULE_PLANS[key] = plan || false
819
+ plan
738
820
  end
739
821
 
740
822
  # `rule.polymorphic` defaults to an empty Hash, which is truthy —
@@ -45,6 +45,18 @@ module Lutaml
45
45
  def initialize(model_class, mapping_dsl, format, register)
46
46
  # Extract register_id before super
47
47
  register_id = extract_register_id(register)
48
+ # #985: the catch-all target (collected unknown keys) — merged
49
+ # after the compiled rules on serialize; mapped keys win. The
50
+ # DSL object is a KeyValue::Mapping by contract, and the target
51
+ # must be a declared attribute: a typo fails here, at compile
52
+ # time, not as a silent no-op on the wire.
53
+ @unmapped_attribute = mapping_dsl.unmapped_attribute
54
+ if @unmapped_attribute &&
55
+ !model_class.attributes(register_id).key?(@unmapped_attribute)
56
+ raise ArgumentError,
57
+ "map_unmapped target :#{@unmapped_attribute} is not a " \
58
+ "declared attribute of #{model_class}"
59
+ end
48
60
  transformation_factory = ->(type_class) {
49
61
  build_child_transformation(type_class)
50
62
  }
@@ -231,6 +243,11 @@ register = self.register)
231
243
  end
232
244
  end
233
245
 
246
+ if @unmapped_attribute
247
+ extras = model_instance.public_send(@unmapped_attribute)
248
+ extras.each { |key, value| root[key] ||= value } if extras.is_a?(::Hash)
249
+ end
250
+
234
251
  if ENV["DEBUG_KEYED_COLLECTION"]
235
252
  puts "=== TRANSFORM COMPLETE ==="
236
253
  puts "root: #{root.inspect}"
@@ -1,6 +1,18 @@
1
1
  module Lutaml
2
2
  module Model
3
3
  class RuleValueExtractor < Services::Base
4
+ # Memoized per rule object: the extractor is fresh per node, so
5
+ # the name array rebuilt per node is pure churn (~145k
6
+ # allocations per heroku.json parse). Rules are stable per
7
+ # (class, register); the bounded cache tracks their arrays.
8
+ RULE_NAMES_CACHE = if Lutaml::Model.opal?
9
+ {}
10
+ else
11
+ Lutaml::Model::RuntimeCompatibility
12
+ .require_native("concurrent")
13
+ Concurrent::Map.new
14
+ end
15
+
4
16
  def initialize(rule, doc, format, attr, register, options,
5
17
  instance_object)
6
18
  super()
@@ -29,7 +41,8 @@ instance_object)
29
41
  :instance_object
30
42
 
31
43
  def rule_names
32
- rule.multiple_mappings? ? rule.name : [rule.name]
44
+ RULE_NAMES_CACHE[rule] ||=
45
+ rule.multiple_mappings? ? rule.name : [rule.name]
33
46
  end
34
47
 
35
48
  def rule_value_for(name)
@@ -4,11 +4,15 @@ module Lutaml
4
4
  module Model
5
5
  module Type
6
6
  class Boolean < Value
7
- def self.cast(value, _options = {})
7
+ def self.cast(value, _options = EMPTY_OPTIONS)
8
8
  return nil if value.nil?
9
9
  return value if Utils.uninitialized?(value)
10
- return true if value == true || value.to_s.match?(/^(true|t|yes|y|1)$/i)
11
- return false if value == false || value.to_s.match?(/^(false|f|no|n|0)$/i)
10
+ # Actual booleans are the dominant input (default resolution,
11
+ # parsed JSON/YAML literals): answer without the two to_s +
12
+ # match? allocations the lexical path costs.
13
+ return value if value.equal?(true) || value.equal?(false)
14
+ return true if value.to_s.match?(/^(true|t|yes|y|1)$/i)
15
+ return false if value.to_s.match?(/^(false|f|no|n|0)$/i)
12
16
 
13
17
  value
14
18
  end
@@ -4,7 +4,7 @@ module Lutaml
4
4
  module Model
5
5
  module Type
6
6
  class Float < Value
7
- def self.cast(value, options = {})
7
+ def self.cast(value, options = EMPTY_OPTIONS)
8
8
  return nil if value.nil?
9
9
  return value if Utils.uninitialized?(value)
10
10
 
@@ -5,7 +5,7 @@ module Lutaml
5
5
  module Type
6
6
  class Integer < Value
7
7
  # Performance-optimized cast with short-circuit for already-correct types
8
- def self.cast(value, options = {})
8
+ def self.cast(value, options = EMPTY_OPTIONS)
9
9
  return nil if value.nil?
10
10
  return value if Utils.uninitialized?(value)
11
11
  # Short-circuit: return immediately if already an Integer
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.121"
5
+ VERSION = "0.8.123"
6
6
  end
7
7
  end
@@ -30,6 +30,7 @@ module Lutaml
30
30
  initialize_empty: true
31
31
  attribute :attribute_group, :attribute_group, collection: true,
32
32
  initialize_empty: true
33
+ attribute :any_attribute, :any_attribute
33
34
 
34
35
  xml do
35
36
  element "complexType"
@@ -50,6 +51,7 @@ module Lutaml
50
51
  map_element :attribute, to: :attribute
51
52
  map_element :annotation, to: :annotation
52
53
  map_element :attributeGroup, to: :attribute_group
54
+ map_element :anyAttribute, to: :any_attribute
53
55
  map_element :simpleContent, to: :simple_content
54
56
  map_element :complexContent, to: :complex_content
55
57
  end
@@ -31,7 +31,7 @@ module Lutaml
31
31
  map_element :sequence, to: :sequence
32
32
  map_element :attribute, to: :attribute
33
33
  map_element :annotation, to: :annotation
34
- map_element :any_attribute, to: :any_attribute
34
+ map_element :anyAttribute, to: :any_attribute
35
35
  map_element :attributeGroup, to: :attribute_group
36
36
  end
37
37
 
@@ -23,7 +23,7 @@ module Lutaml
23
23
  map_attribute :base, to: :base
24
24
  map_element :attribute, to: :attribute
25
25
  map_element :annotation, to: :annotation
26
- map_element :any_attribute, to: :any_attribute
26
+ map_element :anyAttribute, to: :any_attribute
27
27
  map_element :attributeGroup, to: :attribute_group
28
28
  end
29
29
 
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # #985: `map_unmapped to:` collects document keys no declared rule claims
6
+ # into the given attribute, so vendor extensions and forward-compatible
7
+ # keywords survive the round-trip. Opt-in: models without the directive
8
+ # keep the drop behavior. Compiled fast paths are ineligible for such
9
+ # models — the collect runs on the interpretive walk.
10
+ RSpec.describe "key-value catch-all mapping (map_unmapped)" do
11
+ let(:schema_class) do
12
+ Class.new(Lutaml::Model::Serializable) do
13
+ attribute :type, :string
14
+ attribute :description, :string
15
+ attribute :extensions, :hash, default: -> { {} }
16
+
17
+ key_value do
18
+ map "type", to: :type
19
+ map "description", to: :description
20
+ map_unmapped to: :extensions
21
+ end
22
+ end
23
+ end
24
+
25
+ let(:plain_class) do
26
+ Class.new(Lutaml::Model::Serializable) do
27
+ attribute :type, :string
28
+
29
+ key_value do
30
+ map "type", to: :type
31
+ end
32
+ end
33
+ end
34
+
35
+ before do
36
+ stub_const("MapUnmappedSchema", schema_class)
37
+ stub_const("MapUnmappedPlain", plain_class)
38
+ end
39
+
40
+ it "collects unmapped keys on parse" do
41
+ model = MapUnmappedSchema.from_json(
42
+ '{"type":"object","x-enumDescriptions":{"a":"A"},"future":true}',
43
+ )
44
+
45
+ expect(model.type).to eq("object")
46
+ expect(model.extensions).to eq(
47
+ "x-enumDescriptions" => { "a" => "A" }, "future" => true,
48
+ )
49
+ end
50
+
51
+ it "never duplicates keys claimed by a rule" do
52
+ model = MapUnmappedSchema.from_json('{"type":"object"}')
53
+
54
+ expect(model.extensions).to eq({})
55
+ end
56
+
57
+ it "re-emits collected keys verbatim on serialization" do
58
+ model = MapUnmappedSchema.from_json(
59
+ '{"type":"object","x-enumDescriptions":{"a":"A"},"future":true}',
60
+ )
61
+ out = JSON.parse(model.to_json)
62
+
63
+ expect(out["x-enumDescriptions"]).to eq("a" => "A")
64
+ expect(out["future"]).to be(true)
65
+ expect(out["type"]).to eq("object")
66
+ end
67
+
68
+ it "lets mapped keys win on collision with collected keys" do
69
+ model = MapUnmappedSchema.new
70
+ model.type = "object"
71
+ model.extensions = { "type" => "extension-wins-only-if-unmapped" }
72
+
73
+ out = JSON.parse(model.to_json)
74
+
75
+ expect(out["type"]).to eq("object")
76
+ end
77
+
78
+ it "round-trips in yaml through the same contract" do
79
+ model = MapUnmappedSchema.from_yaml(
80
+ <<~YAML,
81
+ type: object
82
+ x-vendor: kept
83
+ YAML
84
+ )
85
+
86
+ expect(model.extensions).to eq("x-vendor" => "kept")
87
+ expect(YAML.safe_load(model.to_yaml)["x-vendor"]).to eq("kept")
88
+ end
89
+
90
+ it "fails fast when the target is not a declared attribute" do
91
+ typo_class = Class.new(Lutaml::Model::Serializable) do
92
+ attribute :type, :string
93
+
94
+ key_value do
95
+ map "type", to: :type
96
+ map_unmapped to: :extenshuns
97
+ end
98
+ end
99
+ stub_const("MapUnmappedTypo", typo_class)
100
+
101
+ # from_json wraps the raise in the format-error contract, message
102
+ # intact; to_json surfaces the bare ArgumentError at compile.
103
+ expect do
104
+ typo_class.from_json('{"type":"object","x-a":1}')
105
+ end.to raise_error(
106
+ Lutaml::Model::InvalidFormatError,
107
+ /map_unmapped target :extenshuns is not a declared attribute of/,
108
+ )
109
+ expect do
110
+ typo_class.new.to_json
111
+ end.to raise_error(
112
+ ArgumentError,
113
+ /map_unmapped target :extenshuns is not a declared attribute of/,
114
+ )
115
+ end
116
+
117
+ it "keeps the drop behavior when no catch-all is declared" do
118
+ model = MapUnmappedPlain.from_json('{"type":"object","x-lost":1}')
119
+
120
+ expect(model.to_json).not_to include("x-lost")
121
+ end
122
+
123
+ it "collects per instance in nested models" do
124
+ container = Class.new(Lutaml::Model::Serializable) do
125
+ attribute :child, "MapUnmappedSchema"
126
+
127
+ key_value do
128
+ map "child", to: :child
129
+ end
130
+ end
131
+ stub_const("MapUnmappedContainer", container)
132
+
133
+ model = MapUnmappedContainer.from_json(
134
+ '{"child":{"type":"object","x-inner":1}}',
135
+ )
136
+
137
+ expect(model.child.extensions).to eq("x-inner" => 1)
138
+ end
139
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "spec_helper"
4
+
5
+ RSpec.describe "xs:anyAttribute mappings" do
6
+ it "maps anyAttribute on a complexType from the parsed XSD" do
7
+ xsd = <<~XSD
8
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
9
+ <xs:complexType name="t">
10
+ <xs:attribute name="a"/>
11
+ <xs:anyAttribute namespace="##other" processContents="strict"/>
12
+ </xs:complexType>
13
+ </xs:schema>
14
+ XSD
15
+
16
+ schema = Lutaml::Xml::Schema::Xsd.parse(xsd)
17
+ wildcard = schema.complex_type.first.any_attribute
18
+
19
+ expect(wildcard).not_to be_nil
20
+ expect(wildcard.namespace).to eq("##other")
21
+ expect(wildcard.process_contents).to eq("strict")
22
+ end
23
+
24
+ it "maps anyAttribute inside a simpleContent extension" do
25
+ xsd = <<~XSD
26
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
27
+ <xs:complexType name="t">
28
+ <xs:simpleContent>
29
+ <xs:extension base="xs:string">
30
+ <xs:anyAttribute namespace="##local"/>
31
+ </xs:extension>
32
+ </xs:simpleContent>
33
+ </xs:complexType>
34
+ </xs:schema>
35
+ XSD
36
+
37
+ schema = Lutaml::Xml::Schema::Xsd.parse(xsd)
38
+ extension = schema.complex_type.first.simple_content.extension
39
+ wildcard = extension.any_attribute
40
+
41
+ expect(wildcard).not_to be_nil
42
+ expect(wildcard.namespace).to eq("##local")
43
+ end
44
+
45
+ it "maps anyAttribute inside a complexContent extension" do
46
+ xsd = <<~XSD
47
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
48
+ <xs:complexType name="t">
49
+ <xs:complexContent>
50
+ <xs:extension base="xs:anyType">
51
+ <xs:anyAttribute namespace="##targetNamespace"/>
52
+ </xs:extension>
53
+ </xs:complexContent>
54
+ </xs:complexType>
55
+ </xs:schema>
56
+ XSD
57
+
58
+ schema = Lutaml::Xml::Schema::Xsd.parse(xsd)
59
+ extension = schema.complex_type.first.complex_content.extension
60
+ wildcard = extension.any_attribute
61
+
62
+ expect(wildcard).not_to be_nil
63
+ expect(wildcard.namespace).to eq("##targetNamespace")
64
+ end
65
+ 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.121
4
+ version: 0.8.123
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-21 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -1778,6 +1778,7 @@ files:
1778
1778
  - spec/lutaml/model/logger_spec.rb
1779
1779
  - spec/lutaml/model/map_all_spec.rb
1780
1780
  - spec/lutaml/model/map_content_spec.rb
1781
+ - spec/lutaml/model/map_unmapped_spec.rb
1781
1782
  - spec/lutaml/model/mapping_rule_perf_guard_spec.rb
1782
1783
  - spec/lutaml/model/mapping_type_validation_spec.rb
1783
1784
  - spec/lutaml/model/mixed_content_spec.rb
@@ -2018,6 +2019,7 @@ files:
2018
2019
  - spec/lutaml/xml/schema/facet_round_trip_spec.rb
2019
2020
  - spec/lutaml/xml/schema/rnc_compiler_spec.rb
2020
2021
  - spec/lutaml/xml/schema/rng_compiler_spec.rb
2022
+ - spec/lutaml/xml/schema/xsd/any_attribute_spec.rb
2021
2023
  - spec/lutaml/xml/schema/xsd/errors/error_context_spec.rb
2022
2024
  - spec/lutaml/xml/schema/xsd/errors/suggestion_spec.rb
2023
2025
  - spec/lutaml/xml/schema/xsd/file_validation_result_spec.rb