lutaml-model 0.8.123 → 0.8.125

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: 6d77a19ece999a4de97c03776b3f56bc31eb217fb14904362eef4c98b4623be6
4
- data.tar.gz: b76ef3e5de721b3e94a02dbac605c61fe3ca44fe97388b8fca1ec4645c3ab494
3
+ metadata.gz: bd52cd1acd33af2b1f8abe39a9d57c4412618d63ee65b516204ef4fa9f7ff047
4
+ data.tar.gz: 903e6d8ed42b95b0c5f79616f362d856b79ef54c90b3bbc1ba854c3345e79bd2
5
5
  SHA512:
6
- metadata.gz: 3a03373b96d83e69500cbd2371557f5ced955e85964b3a92340e8bf3cb4da0b45ea3f83ed59fc6f47e410661c5a2e0ca0b9a5d2db6d890ce9d192ed35ccda2c1
7
- data.tar.gz: 6675bdd426008a94d3e1367b584d3ce92e242b6d6e6e3a85c1e7afc544ab57cce7da847a7af53c9a3f11bd1bfb4f3b49cefb1032eb8157a5e2ddee32b4761545
6
+ metadata.gz: 125f630b5d0816ad0dbbf98e1650f9a221a19c83793fe05c38e384fa884ec251774e97b0ce9636be1a014f84f678fc2c4d42d9cbea108fc615051fbd1bf4112c
7
+ data.tar.gz: 9eec80327bca08b7cc3bb649c77c1b956356baa190d434ae86c5eebacaacfda95c5e2ed2392d6c1d2db7a98fa4a7e32d6d416eb74185d5f0ba8ba416cef66d2a
@@ -59,6 +59,7 @@ require "lutaml/model/context_registry"
59
59
  require "lutaml/model/deep_dupable"
60
60
  require "lutaml/model/error"
61
61
  require "lutaml/model/errors"
62
+ require "lutaml/model/evolution_steps"
62
63
  require "lutaml/model/finalization_cache"
63
64
  require "lutaml/model/format_registry"
64
65
  require "lutaml/model/global_context"
@@ -283,6 +284,7 @@ require "lutaml/model/error/unknown_discriminator_error"
283
284
  require "lutaml/model/error/unknown_sequence_mapping_error"
284
285
  require "lutaml/model/error/unknown_type_error"
285
286
  require "lutaml/model/error/unresolvable_type_error"
287
+ require "lutaml/model/error/unsupported_version_error"
286
288
  require "lutaml/model/error/validation_error"
287
289
  require "lutaml/model/error/validation_failed_error"
288
290
  require "lutaml/model/liquid/indexed_access"
@@ -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,20 +745,28 @@ 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
754
759
 
755
760
  value = rule.transform_value(attr, value, :from, format)
761
+ # Hash/child-mapping rules translate a hash document; an absent
762
+ # or explicit-null key has nothing to translate — the attribute
763
+ # keeps its uninitialized/default state (a nil here previously
764
+ # crashed translate_mappings).
765
+ return if value.nil? && kind == :hash_map
766
+
756
767
  value = translate_mappings(value, rule.hash_mappings, attr, format,
757
768
  instance)
758
- unless rule.hash_mappings
769
+ unless kind == :hash_map
759
770
  value = cast_value(value, attr, format, rule,
760
771
  instance)
761
772
  end
@@ -786,7 +797,8 @@ partition = nil)
786
797
  # Guard + cached target — an inline cache per rule. Complex rules
787
798
  # (custom methods, transforms, value maps, delegates, hash mappings,
788
799
  # unions, polymorphism) return nil and keep the interpretive path.
789
- KvRulePlan = ::Struct.new(:wire, :klass, :collection)
800
+ WalkPlan = ::Struct.new(:kind, :attr, :valid, :wire, :klass,
801
+ :collection)
790
802
 
791
803
  # Memoized (per transform) wire name for plain rules — the from
792
804
  # spelling never changes after definition.
@@ -808,15 +820,48 @@ partition = nil)
808
820
  @kv_serialize_plans[rule] ||= KvSerializePlan.new(rule_from_name(rule))
809
821
  end
810
822
 
811
- def kv_rule_plan(format, rule, 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?
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
816
863
 
817
- plan = build_kv_rule_plan(format, rule, attr)
818
- KV_RULE_PLANS[key] = plan || false
819
- plan
864
+ plan || WalkPlan.new(kind, attr, valid, nil, nil, false)
820
865
  end
821
866
 
822
867
  # `rule.polymorphic` defaults to an empty Hash, which is truthy —
@@ -843,7 +888,8 @@ partition = nil)
843
888
  !attr.value_policy.whole_value?(type) &&
844
889
  !Lutaml::Model::Attribute.custom_from_probe?(type)
845
890
 
846
- KvRulePlan.new(rule.name.to_s, type, attr.collection?)
891
+ WalkPlan.new(:scalar, attr, true, rule.name.to_s, type,
892
+ attr.collection?)
847
893
  end
848
894
 
849
895
  # Extraction fast path: plain hash fetch for a present, non-blank
@@ -0,0 +1,14 @@
1
+ module Lutaml
2
+ module Model
3
+ # Raised when version detection reports a model version that no class
4
+ # in the detected model's family declares (#265).
5
+ class UnsupportedVersionError < Error
6
+ def initialize(model_class, version)
7
+ super(
8
+ "no model in the #{model_class} family declares version " \
9
+ "#{version.inspect}",
10
+ )
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ # Step collector for the `evolution from:` DSL (#265). Each declared
6
+ # step is a closure applied in declaration order to the source
7
+ # document's wire hash; the evolved hash is then parsed by the
8
+ # target model. All steps are wire-level: they relocate, merge and
9
+ # drop keys of the old document — the model pipeline never sees a
10
+ # half-migrated shape.
11
+ class EvolutionSteps
12
+ def initialize
13
+ @steps = []
14
+ end
15
+
16
+ attr_reader :steps
17
+
18
+ # Accept a document written with a former wire name for the
19
+ # current attribute. The current name wins when both appear.
20
+ def wire_rename(from_key, to:)
21
+ from_key = from_key.to_s
22
+ to = to.to_s
23
+ @steps << lambda do |hash|
24
+ next hash unless hash.key?(from_key)
25
+
26
+ hash[to] = hash.delete(from_key) unless hash.key?(to)
27
+ hash
28
+ end
29
+ end
30
+
31
+ # Relocate a former top-level key into a nested position, given
32
+ # as a dot path ("metadata.name") or an array of segments.
33
+ # Intermediate objects are created as needed.
34
+ def wire_move(from_key, to:)
35
+ path = to.is_a?(::Array) ? to.map(&:to_s) : to.split(".").map(&:to_s)
36
+ from_key = from_key.to_s
37
+ @steps << lambda do |hash|
38
+ value = hash.delete(from_key)
39
+ next hash if value.nil?
40
+
41
+ target = hash
42
+ path[0...-1].each do |segment|
43
+ target = (target[segment] ||= {})
44
+ end
45
+ target[path[-1]] = value
46
+ hash
47
+ end
48
+ end
49
+
50
+ # Combine former keys into one. With `with:`, it receives the
51
+ # source values in declared order and returns the new value;
52
+ # without it, the sources must all be hashes and are merged in
53
+ # declared order.
54
+ def wire_merge(from_keys, to:, with: nil)
55
+ keys = from_keys.map(&:to_s)
56
+ to = to.to_s
57
+ @steps << lambda do |hash|
58
+ values = keys.map { |key| hash.delete(key) }
59
+ hash[to] =
60
+ if with
61
+ with.call(*values)
62
+ elsif values.all?(::Hash)
63
+ values.reduce { |merged, value| merged.merge(value) }
64
+ else
65
+ raise ArgumentError,
66
+ "wire_merge requires with: when the merged values " \
67
+ "are not all hashes"
68
+ end
69
+ hash
70
+ end
71
+ end
72
+
73
+ # Retire former keys explicitly. Documentation as much as
74
+ # behavior: an undeclared unknown key keeps the map_unmapped /
75
+ # drop semantics; a declared one states the vocabulary removed it.
76
+ def wire_drop(*keys)
77
+ dropped = keys.map(&:to_s)
78
+ @steps << lambda do |hash|
79
+ dropped.each { |key| hash.delete(key) }
80
+ hash
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -521,6 +521,167 @@ module Lutaml
521
521
  end
522
522
  end
523
523
 
524
+ # Model evolution (#265): `version 1` (or a semver string)
525
+ # stamps the model schema version. Subclasses inherit the
526
+ # nearest ancestor's version; a family is the ancestor chain of
527
+ # version-declared classes, and detection routes within it.
528
+ def version(value = nil)
529
+ return @lutaml_model_version = value if value
530
+
531
+ return @lutaml_model_version if defined?(@lutaml_model_version)
532
+
533
+ if superclass.is_a?(::Class) &&
534
+ superclass < ::Lutaml::Model::Serializable
535
+ return superclass.version
536
+ end
537
+
538
+ nil
539
+ end
540
+
541
+ alias model_version version
542
+
543
+ # Wire-level version detection for the family (#265): the block
544
+ # receives the parsed document hash and returns the version the
545
+ # document was written against. Declared on the family head;
546
+ # `from_<format>` routes to the detected version's class before
547
+ # parsing. Opt-in — without a detector, parsing is unchanged.
548
+ def detect_version(&block)
549
+ return @lutaml_version_detector = block if block
550
+
551
+ return @lutaml_version_detector if defined?(@lutaml_version_detector)
552
+
553
+ if superclass.is_a?(::Class) &&
554
+ superclass < ::Lutaml::Model::Serializable
555
+ return superclass.detect_version
556
+ end
557
+
558
+ nil
559
+ end
560
+
561
+ # Declares wire-level migration steps from a former model
562
+ # version (#265). The block is evaluated on an EvolutionSteps
563
+ # collector (wire_rename, wire_move, wire_merge, wire_drop);
564
+ # the steps are applied by `evolve`, in declaration order, to a
565
+ # copy of the source document's wire hash before the target
566
+ # model parses it.
567
+ def evolution(from:, &block)
568
+ unless from.is_a?(::Class) && from < ::Lutaml::Model::Serializable
569
+ raise ArgumentError,
570
+ "evolution from: must be a Lutaml::Model::Serializable " \
571
+ "model class"
572
+ end
573
+
574
+ collector = Lutaml::Model::EvolutionSteps.new
575
+ collector.instance_eval(&block)
576
+ lutaml_evolutions[from] = collector.steps.freeze
577
+ nil
578
+ end
579
+
580
+ def lutaml_evolutions
581
+ @lutaml_evolutions ||= {}
582
+ end
583
+
584
+ # The class of the family that declares `version == value`.
585
+ # Walks the ancestor chain nearest-first; raises
586
+ # UnsupportedVersionError when no family member declares it.
587
+ def version_class_for(value)
588
+ klass = self
589
+ while klass.is_a?(::Class) &&
590
+ klass < ::Lutaml::Model::Serializable
591
+ return klass if klass.version == value
592
+
593
+ klass = klass.superclass
594
+ end
595
+
596
+ raise ::Lutaml::Model::UnsupportedVersionError.new(self, value)
597
+ end
598
+
599
+ # Migrate a document or instance of a former model version into
600
+ # this one (#265). The source's wire hash (its own `to_hash`
601
+ # for an instance) passes through the declared evolution chain
602
+ # — every family link between the source version and this
603
+ # class, in order — and the result parses as this model.
604
+ #
605
+ # @param source [Hash, Serializable] the old document or model
606
+ # @param from [Class] the source model class
607
+ # @param format [Symbol] the key-value format used to parse
608
+ # the evolved hash
609
+ def evolve(source, from:)
610
+ chain = evolution_chain_from(from)
611
+ wire = if source.is_a?(::Hash)
612
+ Lutaml::Model::Utils.deep_dup(source)
613
+ else
614
+ source.to_hash
615
+ end
616
+ chain.each do |link_source|
617
+ steps = evolution_steps_for(link_source)
618
+ wire = steps.reduce(wire) { |hash, step| step.call(hash) }
619
+ end
620
+ from_hash(wire)
621
+ end
622
+
623
+ # Family members nearest-first, self included.
624
+ def evolution_family
625
+ members = []
626
+ klass = self
627
+ while klass.is_a?(::Class) &&
628
+ klass < ::Lutaml::Model::Serializable
629
+ members << klass
630
+ klass = klass.superclass
631
+ end
632
+ members
633
+ end
634
+
635
+ # Shortest declared-evolution path from the source class to
636
+ # self, as the list of link source classes. Edges are the
637
+ # family's `evolution from:` declarations. (Public: also serves
638
+ # introspection of declared family links.)
639
+ def evolution_chain_from(source_class)
640
+ return [] if source_class == self
641
+
642
+ edges = {}
643
+ evolution_family.each do |member|
644
+ member.lutaml_evolutions.each_key do |source|
645
+ (edges[source] ||= []) << member
646
+ end
647
+ end
648
+
649
+ parents = { source_class => nil }
650
+ frontier = [source_class]
651
+ until frontier.empty? || parents[self]
652
+ current = frontier.shift
653
+ (edges[current] || []).each do |target|
654
+ next if parents.key?(target)
655
+
656
+ parents[target] = current
657
+ frontier << target
658
+ end
659
+ end
660
+
661
+ unless parents[self]
662
+ raise ArgumentError,
663
+ "no evolution path from #{source_class} to #{self} " \
664
+ "is declared"
665
+ end
666
+
667
+ path = []
668
+ cursor = self
669
+ while cursor != source_class
670
+ path.unshift(cursor)
671
+ cursor = parents[cursor]
672
+ end
673
+ path.unshift(source_class)[0...-1]
674
+ end
675
+
676
+ # The family member (nearest-first) that declared steps for the
677
+ # given link source.
678
+ def evolution_steps_for(link_source)
679
+ member = evolution_family.find do |klass|
680
+ klass.lutaml_evolutions.key?(link_source)
681
+ end
682
+ member.lutaml_evolutions[link_source]
683
+ end
684
+
524
685
  # Define an attribute for the model
525
686
  #
526
687
  # @param name [Symbol] The attribute name
@@ -75,6 +75,18 @@ module Lutaml
75
75
 
76
76
  def self.register_from_format_method(format)
77
77
  ClassMethods.define_method(:"from_#{format}") do |data, options = {}|
78
+ # #265 version routing: with a family detector declared, the
79
+ # document's version picks the family class that parses it.
80
+ # Terminates because the routed class resolves the same
81
+ # detected version to itself and parses directly.
82
+ detector = detect_version
83
+ if detector
84
+ parsed = ::Lutaml::Model::Config.adapter_for(format)
85
+ .parse(data, options)
86
+ detected = detector.call(parsed)
87
+ routed = version_class_for(detected) unless detected.nil?
88
+ return routed.public_send(:"from_#{format}", data, options) if routed && routed != self
89
+ end
78
90
  from(format, data, options)
79
91
  end
80
92
 
@@ -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.123"
5
+ VERSION = "0.8.125"
6
6
  end
7
7
  end
data/lib/lutaml/model.rb CHANGED
@@ -46,6 +46,7 @@ module Lutaml
46
46
  autoload :NamespaceBinding, "#{__dir__}/model/register/namespace_binding"
47
47
  autoload :ModelTreeImporter, "#{__dir__}/model/register/model_tree_importer"
48
48
  autoload :Transformation, "#{__dir__}/model/transformation"
49
+ autoload :EvolutionSteps, "#{__dir__}/model/evolution_steps"
49
50
  autoload :CompiledRule, "#{__dir__}/model/compiled_rule"
50
51
  autoload :FormatRegistry, "#{__dir__}/model/format_registry"
51
52
  autoload :OneEntryCache, "#{__dir__}/model/one_entry_cache"
@@ -161,6 +162,8 @@ module Lutaml
161
162
  autoload :NoRootMappingError, "#{__dir__}/model/error/no_root_mapping_error"
162
163
  autoload :ImportModelWithRootError,
163
164
  "#{__dir__}/model/error/import_model_with_root_error"
165
+ autoload :UnsupportedVersionError,
166
+ "#{__dir__}/model/error/unsupported_version_error"
164
167
  autoload :InvalidChoiceRangeError,
165
168
  "#{__dir__}/model/error/invalid_choice_range_error"
166
169
  autoload :UnknownSequenceMappingError,
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # A child_mappings rule whose wire key is absent crashed translate_mappings
6
+ # with an internal NoMethodError (nil.map) wrapped as InvalidFormatError.
7
+ # An absent key means the attribute keeps its uninitialized/default state,
8
+ # exactly like every other rule kind.
9
+ RSpec.describe "child_mappings rule with an absent key" do
10
+ let(:schema_class) do
11
+ Class.new(Lutaml::Model::Serializable) do
12
+ attribute :id, :string
13
+ attribute :path, :string
14
+ end
15
+ end
16
+
17
+ let(:model_class) do
18
+ schemas = schema_class
19
+ Class.new(Lutaml::Model::Serializable) do
20
+ attribute :type, :string
21
+ attribute :schemas, schemas, collection: true
22
+
23
+ key_value do
24
+ map "type", to: :type
25
+ map "schemas", to: :schemas,
26
+ child_mappings: {
27
+ id: :key,
28
+ path: %i[path link],
29
+ }
30
+ end
31
+ end
32
+ end
33
+
34
+ before { stub_const("ChildMapAbsentModel", model_class) }
35
+
36
+ it "parses without the key, leaving the attribute unset" do
37
+ model = ChildMapAbsentModel.from_json('{"type":"object"}')
38
+
39
+ expect(model.type).to eq("object")
40
+ expect(model.schemas).to be_nil
41
+ end
42
+
43
+ it "still translates when the key is present" do
44
+ model = ChildMapAbsentModel.from_json(
45
+ '{"type":"object","schemas":{' \
46
+ '"sch1":{"path":{"link":"https://example.com"}}}}',
47
+ )
48
+
49
+ expect(model.schemas.size).to eq(1)
50
+ expect(model.schemas.first.id).to eq("sch1")
51
+ expect(model.schemas.first.path).to eq("https://example.com")
52
+ end
53
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # #265: model evolution — version metadata, wire-level migration steps
6
+ # between model versions, and family version detection. The migration
7
+ # steps operate on the source document's wire hash (deep-copied); the
8
+ # evolved hash parses as the target model through the hash format.
9
+ RSpec.describe "model evolution" do
10
+ let(:v1_class) do
11
+ Class.new(Lutaml::Model::Serializable) do
12
+ version 1
13
+
14
+ attribute :author, :string
15
+ attribute :name, :string
16
+ attribute :first_name, :string
17
+ attribute :last_name, :string
18
+ attribute :tag, :string
19
+ attribute :deprecated_field, :string
20
+
21
+ key_value do
22
+ map "author", to: :author
23
+ map "name", to: :name
24
+ map "first_name", to: :first_name
25
+ map "last_name", to: :last_name
26
+ map "tag", to: :tag
27
+ map "deprecated_field", to: :deprecated_field
28
+ end
29
+ end
30
+ end
31
+
32
+ let(:v2_class) do
33
+ v1 = v1_class
34
+ Class.new(v1) do
35
+ version 2
36
+
37
+ attribute :creator, :string
38
+ attribute :full_name, :string
39
+ attribute :tags, :string, collection: true
40
+ attribute :metadata_name, :string
41
+
42
+ key_value do
43
+ map "creator", to: :creator
44
+ map "full_name", to: :full_name
45
+ map "tags", to: :tags
46
+ map "metadata", to: :metadata_name,
47
+ child_mappings: { metadata_name: :name }
48
+ map "deprecated_field", to: :deprecated_field, serialize: false
49
+ end
50
+
51
+ evolution from: v1 do
52
+ wire_rename "author", to: "creator"
53
+ wire_rename "tag", to: "tags"
54
+ wire_merge %w[first_name last_name], to: "full_name",
55
+ with: ->(first, last) { [first, last].compact.join(" ") }
56
+ wire_drop "first_name", "last_name", "deprecated_field"
57
+ end
58
+ end
59
+ end
60
+
61
+ before do
62
+ stub_const("EvolutionDocV1", v1_class)
63
+ stub_const("EvolutionDocV2", v2_class)
64
+ end
65
+
66
+ it "stamps and inherits the model version" do
67
+ expect(EvolutionDocV1.model_version).to eq(1)
68
+ expect(EvolutionDocV2.model_version).to eq(2)
69
+ end
70
+
71
+ it "migrates a v1 hash into a v2 model through declared steps" do
72
+ v2 = EvolutionDocV2.evolve(
73
+ { "author" => "Ruth", "first_name" => "Ada", "last_name" => "L" },
74
+ from: EvolutionDocV1,
75
+ )
76
+
77
+ expect(v2).to be_a(EvolutionDocV2)
78
+ expect(v2.creator).to eq("Ruth")
79
+ expect(v2.full_name).to eq("Ada L")
80
+ end
81
+
82
+ it "migrates a v1 instance through its own wire hash" do
83
+ old = EvolutionDocV1.from_json(
84
+ '{"author":"Ruth","tag":"core","deprecated_field":"gone"}',
85
+ )
86
+ v2 = EvolutionDocV2.evolve(old, from: EvolutionDocV1)
87
+
88
+ expect(v2.creator).to eq("Ruth")
89
+ expect(v2.tags).to eq(["core"])
90
+ end
91
+
92
+ it "does not mutate the caller's source hash" do
93
+ source = { "author" => "Ruth", "first_name" => "Ada" }
94
+ EvolutionDocV2.evolve(source, from: EvolutionDocV1)
95
+
96
+ expect(source).to eq("author" => "Ruth", "first_name" => "Ada")
97
+ end
98
+
99
+ it "relocates keys with wire_move" do
100
+ mover = Class.new(EvolutionDocV1) do
101
+ version 3
102
+ attribute :meta, :hash
103
+
104
+ key_value do
105
+ map "meta", to: :meta
106
+ end
107
+
108
+ evolution from: EvolutionDocV2 do
109
+ wire_move "creator", to: "meta.creator"
110
+ end
111
+ end
112
+ stub_const("EvolutionDocV3", mover)
113
+
114
+ v3 = mover.evolve(
115
+ { "creator" => "Ruth", "name" => "n" },
116
+ from: EvolutionDocV2,
117
+ )
118
+
119
+ expect(v3.meta).to eq("creator" => "Ruth")
120
+ end
121
+
122
+ it "composes chains v1 -> v2 -> v3" do
123
+ mover = Class.new(EvolutionDocV2) do
124
+ version 3
125
+
126
+ evolution from: EvolutionDocV2 do
127
+ wire_drop "tag"
128
+ end
129
+ end
130
+ stub_const("EvolutionDocV3Chain", mover)
131
+
132
+ v3 = mover.evolve(
133
+ { "author" => "Ruth", "first_name" => "Ada", "tag" => "x" },
134
+ from: EvolutionDocV1,
135
+ )
136
+
137
+ expect(v3).to be_a(EvolutionDocV3Chain)
138
+ expect(v3.creator).to eq("Ruth")
139
+ end
140
+
141
+ it "raises when no evolution path is declared" do
142
+ unrelated = Class.new(Lutaml::Model::Serializable) do
143
+ version 9
144
+ attribute :x, :string
145
+ key_value { map "x", to: :x }
146
+ end
147
+ stub_const("EvolutionUnrelated", unrelated)
148
+
149
+ expect do
150
+ EvolutionUnrelated.evolve({ "x" => 1 }, from: EvolutionDocV1)
151
+ end.to raise_error(
152
+ ArgumentError,
153
+ /no evolution path from EvolutionDocV1 to EvolutionUnrelated/,
154
+ )
155
+ end
156
+
157
+ describe "version detection" do
158
+ let(:detecting_class) do
159
+ Class.new(v2_class) do
160
+ detect_version do |doc|
161
+ doc["schema_version"] || 1
162
+ end
163
+ end
164
+ end
165
+
166
+ before { stub_const("EvolutionDetecting", detecting_class) }
167
+
168
+ it "routes an older document to its own version's model" do
169
+ parsed = EvolutionDetecting.from_json(
170
+ '{"schema_version":1,"author":"Ruth"}',
171
+ )
172
+
173
+ expect(parsed).to be_a(EvolutionDocV1)
174
+ expect(parsed.author).to eq("Ruth")
175
+ end
176
+
177
+ it "parses a current document as itself" do
178
+ parsed = EvolutionDetecting.from_json(
179
+ '{"schema_version":2,"creator":"Ruth"}',
180
+ )
181
+
182
+ expect(parsed).to be_a(EvolutionDetecting)
183
+ expect(parsed.creator).to eq("Ruth")
184
+ end
185
+
186
+ it "raises on a version no family member declares" do
187
+ expect do
188
+ EvolutionDetecting.from_json('{"schema_version":42}')
189
+ end.to raise_error(
190
+ Lutaml::Model::UnsupportedVersionError,
191
+ /version 42/,
192
+ )
193
+ end
194
+ end
195
+
196
+ describe "models without evolution declarations" do
197
+ it "parses unchanged" do
198
+ model = v1_class.from_json('{"author":"Ruth"}')
199
+
200
+ expect(model.author).to eq("Ruth")
201
+ expect(v1_class.model_version).to eq(1)
202
+ end
203
+ end
204
+ 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.123
4
+ version: 0.8.125
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -500,9 +500,11 @@ files:
500
500
  - lib/lutaml/model/error/unknown_sequence_mapping_error.rb
501
501
  - lib/lutaml/model/error/unknown_type_error.rb
502
502
  - lib/lutaml/model/error/unresolvable_type_error.rb
503
+ - lib/lutaml/model/error/unsupported_version_error.rb
503
504
  - lib/lutaml/model/error/validation_error.rb
504
505
  - lib/lutaml/model/error/validation_failed_error.rb
505
506
  - lib/lutaml/model/errors.rb
507
+ - lib/lutaml/model/evolution_steps.rb
506
508
  - lib/lutaml/model/finalization_cache.rb
507
509
  - lib/lutaml/model/format_registry.rb
508
510
  - lib/lutaml/model/global_context.rb
@@ -1716,6 +1718,7 @@ files:
1716
1718
  - spec/lutaml/model/cardinality_strict_spec.rb
1717
1719
  - spec/lutaml/model/cast_element_form_spec.rb
1718
1720
  - spec/lutaml/model/cdata_spec.rb
1721
+ - spec/lutaml/model/child_mappings_absent_key_spec.rb
1719
1722
  - spec/lutaml/model/choice_restrict_spec.rb
1720
1723
  - spec/lutaml/model/choice_spec.rb
1721
1724
  - spec/lutaml/model/cli_spec.rb
@@ -1750,6 +1753,7 @@ files:
1750
1753
  - spec/lutaml/model/dynamic_attribute_spec.rb
1751
1754
  - spec/lutaml/model/encoding_spec.rb
1752
1755
  - spec/lutaml/model/enum_spec.rb
1756
+ - spec/lutaml/model/evolution_spec.rb
1753
1757
  - spec/lutaml/model/except_spec.rb
1754
1758
  - spec/lutaml/model/finalization_cache_spec.rb
1755
1759
  - spec/lutaml/model/from_json_scalar_cast_spec.rb
@@ -1868,6 +1872,7 @@ files:
1868
1872
  - spec/lutaml/model/uninitialized_class_deep_dup_spec.rb
1869
1873
  - spec/lutaml/model/uninitialized_class_spec.rb
1870
1874
  - spec/lutaml/model/union_attribute_spec.rb
1875
+ - spec/lutaml/model/union_lenient_model_spec.rb
1871
1876
  - spec/lutaml/model/union_spec.rb
1872
1877
  - spec/lutaml/model/unregistered_context_resolution_spec.rb
1873
1878
  - spec/lutaml/model/utils_spec.rb