lutaml-model 0.8.124 → 0.8.126

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: f4c03c6aacd7f243f8c919c1561cada699b4d6a06fa1fa7f48e7847787dcbef2
4
- data.tar.gz: bdadc3e2abadd6df36b913d19ec281771e5ea4b68ec74f58b1566f93a6053242
3
+ metadata.gz: 0b25c117248ae09d0b9a264e2d28fd7a34e8701e97c745c0169c92e53309a70b
4
+ data.tar.gz: ca1949cf1b6b3ad95b3640c14ed80ef34f6d741e7f6d66b50b9ad6d7a42664f1
5
5
  SHA512:
6
- metadata.gz: '09ac4538324403ddc363c1b75ecedb9499a5361c399894287c83ec1ffb52cd4169cc6ea050f1dd4242c6d834b5df55845e17fa1b0c2e7b10b21c6639a52c7c26'
7
- data.tar.gz: 8322eeba63f6c0b4ff546629bb81801a80d9b19b3ee13ab1fd72d5265c52b7f3248f28c498b59f3b6eb135fbe78cb73632f0cdd609cab821a5a792fd374251a6
6
+ metadata.gz: dc5fe5fc88f3a1744f61e447dbb863f22f2d3314086b681d785d90e62f1ca2ef1e42bb156edae5bbd6f920ea3628f877623a16a0f8bfa49022ca93200eda887d
7
+ data.tar.gz: 46c3f3966f2d7bf6a14292cb311544d40b5bdb561dd9a0a17c21d06ac257fd71ff9d1e427bb14697e781888c2aaade62a2e28c6bdbea2e89e316645b4c366428
@@ -165,14 +165,15 @@ module Lutaml
165
165
  Concurrent::Map.new
166
166
  end
167
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?
168
+ # [model class, register, format, generation] -> { rule => plan }.
169
+ # Class-level, generation-retired: the whole dispatch table for a
170
+ # model mapping compiles once — kind (scalar/custom/hash_map/
171
+ # interpretive/skip/invalid), target attribute and validity — so
172
+ # process_mapping_rule stops re-probing rule predicates at every
173
+ # node (~12 predicate calls plus a key array per rule per node;
174
+ # 154k+154k allocations per heroku.json parse). Same
175
+ # idempotent-write pattern as KV_GROUP_PLANS.
176
+ KV_WALK_PLANS = if Lutaml::Model.opal?
176
177
  {}
177
178
  else
178
179
  Lutaml::Model::RuntimeCompatibility
@@ -717,18 +718,20 @@ format)
717
718
 
718
719
  def process_mapping_rule(doc, instance, format, rule, options = {},
719
720
  partition = nil)
720
- attr = attribute_for_rule(rule)
721
- return if attr&.derived?
721
+ plans = walk_plans_for(format)
722
+ plan = plans[rule] ||= build_walk_plan(format, rule)
723
+ kind = plan.kind
724
+ return if kind == :skip
722
725
 
723
- raise "Attribute '#{rule.to}' not found in #{self}" unless valid_rule?(
724
- rule, attr
725
- )
726
+ raise "Attribute '#{rule.to}' not found in #{self}" unless plan.valid
727
+
728
+ attr = plan.attr
726
729
 
727
730
  # lutaml-model#88: discriminator rules (and plain rules on a
728
731
  # partitioned key) must filter the raw value, so they keep the
729
732
  # interpretive path.
730
733
  partitioned = partition && kv_partition_group(rule, partition)
731
- if partitioned.nil? && (plan = kv_rule_plan(format, rule, attr)) &&
734
+ if partitioned.nil? && kind == :scalar &&
732
735
  (value = kv_fast_extract(doc, plan))
733
736
  rule.deserialize(instance, kv_fast_cast(value, plan, instance),
734
737
  attributes, self, options[:context], pre_cast: true)
@@ -742,12 +745,14 @@ partition = nil)
742
745
  end
743
746
  value = apply_value_map(value, rule.value_map(:from, options), attr)
744
747
 
745
- if rule.has_custom_method_for_deserialization?
746
- # An empty value ("", [], {}) is a present value per the
747
- # missing-values semantics and must reach the custom method;
748
- # only nil (non-existent) skips it (lutaml-model#746).
749
- return if value.nil?
748
+ # An empty value ("", [], {}) is a present value per the
749
+ # missing-values semantics and must reach the custom method;
750
+ # only nil (non-existent) skips it (lutaml-model#746).
751
+ if (kind == :custom) && value.nil?
752
+ return
753
+ end
750
754
 
755
+ if kind == :custom
751
756
  return rule.deserialize(instance, value, attributes, model_class,
752
757
  options[:context])
753
758
  end
@@ -757,11 +762,11 @@ partition = nil)
757
762
  # or explicit-null key has nothing to translate — the attribute
758
763
  # keeps its uninitialized/default state (a nil here previously
759
764
  # crashed translate_mappings).
760
- return if value.nil? && (rule.hash_mappings || rule.child_mappings)
765
+ return if value.nil? && kind == :hash_map
761
766
 
762
767
  value = translate_mappings(value, rule.hash_mappings, attr, format,
763
768
  instance)
764
- unless rule.hash_mappings
769
+ unless kind == :hash_map
765
770
  value = cast_value(value, attr, format, rule,
766
771
  instance)
767
772
  end
@@ -792,7 +797,8 @@ partition = nil)
792
797
  # Guard + cached target — an inline cache per rule. Complex rules
793
798
  # (custom methods, transforms, value maps, delegates, hash mappings,
794
799
  # unions, polymorphism) return nil and keep the interpretive path.
795
- KvRulePlan = ::Struct.new(:wire, :klass, :collection)
800
+ WalkPlan = ::Struct.new(:kind, :attr, :valid, :wire, :klass,
801
+ :collection)
796
802
 
797
803
  # Memoized (per transform) wire name for plain rules — the from
798
804
  # spelling never changes after definition.
@@ -814,15 +820,48 @@ partition = nil)
814
820
  @kv_serialize_plans[rule] ||= KvSerializePlan.new(rule_from_name(rule))
815
821
  end
816
822
 
817
- def kv_rule_plan(format, rule, attr)
818
- key = [model_class, lutaml_register, format, rule,
819
- Lutaml::Model::GlobalContext.context_generation]
820
- cached = KV_RULE_PLANS[key]
821
- return cached == false ? nil : cached unless cached.nil?
823
+ def walk_plans_for(format)
824
+ generation = Lutaml::Model::GlobalContext.context_generation
825
+ if @lutaml_walk_plans.nil? || @lutaml_walk_generation != generation ||
826
+ @lutaml_walk_format != format
827
+ key = [model_class, lutaml_register, format, generation]
828
+ @lutaml_walk_plans =
829
+ (KV_WALK_PLANS[key] ||= build_walk_plans(format))
830
+ @lutaml_walk_generation = generation
831
+ @lutaml_walk_format = format
832
+ end
833
+ @lutaml_walk_plans
834
+ end
835
+
836
+ def build_walk_plans(format)
837
+ plans = {}.compare_by_identity
838
+ mappings_for(format).mappings(lutaml_register).each do |rule|
839
+ plans[rule] = build_walk_plan(format, rule)
840
+ end
841
+ plans
842
+ end
843
+
844
+ # The per-rule dispatch compile. Kind precedence mirrors the
845
+ # interpretive method exactly: skip (derived) > invalid >
846
+ # custom (deserialization custom method) > hash_map
847
+ # (hash_mappings truthy — translate, never cast) > scalar (the
848
+ # fast-extract eligibility) > interpretive.
849
+ def build_walk_plan(format, rule, _attrs = nil)
850
+ attr = attribute_for_rule(rule)
851
+ return WalkPlan.new(:skip, nil, false, nil, nil, false) if attr&.derived?
852
+
853
+ valid = valid_rule?(rule, attr)
854
+ kind =
855
+ if rule.has_custom_method_for_deserialization?
856
+ :custom
857
+ elsif rule.hash_mappings
858
+ :hash_map
859
+ else
860
+ plan = build_kv_rule_plan(format, rule, attr)
861
+ plan ? :scalar : :interpretive
862
+ end
822
863
 
823
- plan = build_kv_rule_plan(format, rule, attr)
824
- KV_RULE_PLANS[key] = plan || false
825
- plan
864
+ plan || WalkPlan.new(kind, attr, valid, nil, nil, false)
826
865
  end
827
866
 
828
867
  # `rule.polymorphic` defaults to an empty Hash, which is truthy —
@@ -849,7 +888,8 @@ partition = nil)
849
888
  !attr.value_policy.whole_value?(type) &&
850
889
  !Lutaml::Model::Attribute.custom_from_probe?(type)
851
890
 
852
- KvRulePlan.new(rule.name.to_s, type, attr.collection?)
891
+ WalkPlan.new(:scalar, attr, true, rule.name.to_s, type,
892
+ attr.collection?)
853
893
  end
854
894
 
855
895
  # Extraction fast path: plain hash fetch for a present, non-blank
@@ -97,7 +97,23 @@ module Lutaml
97
97
  end
98
98
  return [member, casted] unless casted == :no_match
99
99
  end
100
- nil
100
+
101
+ lenient_model_member(value, members, format, register)
102
+ end
103
+
104
+ # With exactly one model member, a Hash that also carries unmapped
105
+ # keys still conforms: unknown keys are ignored by every non-union
106
+ # model attribute, and strict coverage would silently drop the
107
+ # whole value. With several model members the strict key-coverage
108
+ # check stays in charge — coverage is what discriminates between
109
+ # them. Plain-Ruby assignment keeps the strict constructor path.
110
+ def lenient_model_member(value, members, format, register)
111
+ return nil unless value.is_a?(::Hash)
112
+ return nil if format.nil?
113
+ return nil unless members.one? { |member| model_member?(member) }
114
+
115
+ member = members.find { |candidate| model_member?(candidate) }
116
+ [member, member.apply_mappings(value, format, register: register)]
101
117
  end
102
118
 
103
119
  # Whether a compiled mapping rule's attribute is a union — the single
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.124"
5
+ VERSION = "0.8.126"
6
6
  end
7
7
  end
@@ -686,14 +686,27 @@ RSpec.describe "Union-typed attributes (issue #190)" do
686
686
  expect(parsed.value).to be(true)
687
687
  end
688
688
 
689
- it "returns nil only when no member conforms at all" do
689
+ it "builds the lone model member even when no key maps (#989: " \
690
+ "non-union parity)" do
690
691
  holder = Class.new(Lutaml::Model::Serializable) do
691
692
  attribute :value, [ExtendedShape, :boolean]
692
693
  key_value { map "value", to: :value }
693
694
  end
694
695
  stub_const("ExtendedHolderNinetyEight", holder)
695
696
 
696
- expect(holder.from_json('{"value":{"unrelated":1}}').value).to be_nil
697
+ value = holder.from_json('{"value":{"unrelated":1}}').value
698
+
699
+ expect(value).to be_a(ExtendedShape)
700
+ end
701
+
702
+ it "returns nil when no member conforms at all" do
703
+ holder = Class.new(Lutaml::Model::Serializable) do
704
+ attribute :value, [ExtendedShape, :boolean]
705
+ key_value { map "value", to: :value }
706
+ end
707
+ stub_const("ExtendedHolderNinetyEightB", holder)
708
+
709
+ expect(holder.from_json('{"value":3}').value).to be_nil
697
710
  end
698
711
  end
699
712
 
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Regression spec for issue #987: with a single model member in a union,
6
+ # a Hash carrying keys the model does not map must still conform. Unknown
7
+ # keys are ignored by every non-union model attribute; strict key
8
+ # coverage silently returned nil for the whole attribute instead.
9
+ module UnionLenientModelSpec
10
+ class Extensible < Lutaml::Model::Serializable
11
+ attribute :type, :string
12
+ attribute :strict_properties, :boolean
13
+
14
+ key_value do
15
+ map "type", to: :type
16
+ map "strictProperties", to: :strict_properties
17
+ end
18
+ end
19
+
20
+ class Holder < Lutaml::Model::Serializable
21
+ attribute :value, [Extensible, :boolean]
22
+
23
+ key_value do
24
+ map "value", to: :value
25
+ end
26
+ end
27
+
28
+ class Other < Lutaml::Model::Serializable
29
+ attribute :celsius, :float
30
+
31
+ key_value do
32
+ map "celsius", to: :celsius
33
+ end
34
+ end
35
+
36
+ class TwoModelHolder < Lutaml::Model::Serializable
37
+ attribute :value, [Extensible, Other]
38
+
39
+ key_value do
40
+ map "value", to: :value
41
+ end
42
+ end
43
+
44
+ RSpec.describe "Union with a single model member accepts unmapped keys" do
45
+ it "builds the model when the hash carries unmapped keys" do
46
+ value = Holder.from_json('{"value":{"type":"string","x-ext":1}}').value
47
+
48
+ expect(value).to be_a(Extensible)
49
+ expect(value.type).to eq("string")
50
+ end
51
+
52
+ it "still accepts hashes with mapped keys only" do
53
+ value = Holder.from_json('{"value":{"type":"string"}}').value
54
+
55
+ expect(value).to be_a(Extensible)
56
+ expect(value.type).to eq("string")
57
+ end
58
+
59
+ it "keeps boolean members working" do
60
+ expect(Holder.from_json('{"value":true}').value).to be(true)
61
+ expect(Holder.from_json('{"value":false}').value).to be(false)
62
+ end
63
+
64
+ it "dispatches to the first model member with a mapped key across " \
65
+ "several members" do
66
+ json = '{"value":{"type":"string","celsius":1.0}}'
67
+
68
+ value = TwoModelHolder.from_json(json).value
69
+
70
+ expect(value).to be_a(Extensible)
71
+ expect(value.type).to eq("string")
72
+ end
73
+
74
+ it "still requires a match when nothing conforms" do
75
+ expect(Holder.from_json('{"value":3}').value).to be_nil
76
+ end
77
+ end
78
+ 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.124
4
+ version: 0.8.126
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -1872,6 +1872,7 @@ files:
1872
1872
  - spec/lutaml/model/uninitialized_class_deep_dup_spec.rb
1873
1873
  - spec/lutaml/model/uninitialized_class_spec.rb
1874
1874
  - spec/lutaml/model/union_attribute_spec.rb
1875
+ - spec/lutaml/model/union_lenient_model_spec.rb
1875
1876
  - spec/lutaml/model/union_spec.rb
1876
1877
  - spec/lutaml/model/unregistered_context_resolution_spec.rb
1877
1878
  - spec/lutaml/model/utils_spec.rb