lutaml-model 0.8.40 → 0.8.41

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: 597b465123fe390703bdb760a4f6e18e4069b55bea8998633e2a4c0c7f5fcb9c
4
- data.tar.gz: e848597e1ad2cef05670d8dc8de6f7480bf028fde627021c5a61f554ad99a2f0
3
+ metadata.gz: 6fc47a2bee1b20995c17426890ae3fdc3f9db24317a8cb0c83ea3e81f13372be
4
+ data.tar.gz: 72b52030485d681b901b077b75195a40d7779c0ec12df982e8bc7f49b3ef9dad
5
5
  SHA512:
6
- metadata.gz: 231f9601a9ce76ae516a6435b2c7185e15c2cc6680d0aefbad27ef328b25f67f0ed2635be8319eff6c3a64338f3bd2f9855ab1daa51058018345eb77cf3aa0d9
7
- data.tar.gz: 88af6f65b2fadf91990f6f6f10a03a799007acda351d87a059f00325c85514291fbaccfd99580dd1daac3090bd61f4aa1ec26efffd058438c7e6abdd9a53cea4
6
+ metadata.gz: dc281b89a9c032cff1f2a38de58b465dccc5385421d9daed7a8244adec62a282a79163b392224a2ead89d93281bbf1d6f5772f879f088a930332ae8821c7784e
7
+ data.tar.gz: 1d0c2b049cc9c45e7dfe0dd0a6ec6326c98011ab09c6a9f32eff39b36a67cfce1dd97f20341d301258acfc9c9eb282d43718d3a7ab0e0401ae29e6d1a936d724
@@ -32,6 +32,32 @@ module Lutaml
32
32
 
33
33
  alias == eql?
34
34
 
35
+ # Order-insensitive equality (lutaml-model#17): collection
36
+ # attributes compare as multisets — document order within a
37
+ # collection does not affect the result. Non-collection
38
+ # attributes and nested models keep strict equality.
39
+ #
40
+ # @param other [Object] the object to compare with
41
+ # @param ignore_element_order [Boolean] compare collections
42
+ # order-insensitively
43
+ # @return [Boolean]
44
+ def same_as?(other, ignore_element_order: true)
45
+ return true if equal?(other)
46
+ return false unless instance_of?(other.class)
47
+
48
+ unordered_eql?(self, other, {}, ignore_element_order)
49
+ end
50
+
51
+ # Structured diff (lutaml-model#18): returns one entry per
52
+ # differing attribute path, recursively through nested models
53
+ # and collections. Equal objects diff to [].
54
+ #
55
+ # @param other [Object] the object to diff against
56
+ # @return [Array<Hash>] [{ path:, left:, right: }] entries
57
+ def diff(other)
58
+ Lutaml::Model::ComparableModel::StructuralDiff.diff(self, other)
59
+ end
60
+
35
61
  def same_class?(other)
36
62
  other.instance_of?(self.class)
37
63
  end
@@ -145,6 +171,72 @@ module Lutaml
145
171
  end
146
172
  end
147
173
 
174
+ # Structured, path-addressed diff between two model trees
175
+ # (lutaml-model#18). Used by ComparableModel#diff.
176
+ module StructuralDiff
177
+ Entry = ::Struct.new(:path, :left, :right)
178
+
179
+ module_function
180
+
181
+ def diff(left, right, path = [])
182
+ return [] if left.equal?(right)
183
+ return [Entry.new(path_string(path), left, right)] unless comparable_pair?(left, right)
184
+
185
+ entries = []
186
+ left.class.attributes.each_key do |attr|
187
+ lval = left.public_send(attr)
188
+ rval = right.public_send(attr)
189
+ child_path = path + [attr]
190
+
191
+ if comparable_pair?(lval, rval)
192
+ entries.concat(diff(lval, rval, child_path))
193
+ elsif lval.is_a?(::Array) && rval.is_a?(::Array)
194
+ entries.concat(diff_collections(lval, rval, child_path))
195
+ elsif differing?(lval, rval)
196
+ entries << Entry.new(path_string(child_path), lval, rval)
197
+ end
198
+ end
199
+ entries
200
+ end
201
+
202
+ def diff_collections(lval, rval, path)
203
+ entries = []
204
+ max = [lval.size, rval.size].max
205
+ max.times do |i|
206
+ li = lval[i]
207
+ ri = rval[i]
208
+ child_path = path + [i]
209
+
210
+ if comparable_pair?(li, ri)
211
+ entries.concat(diff(li, ri, child_path))
212
+ elsif differing?(li, ri)
213
+ entries << Entry.new(path_string(child_path), li, ri)
214
+ end
215
+ end
216
+ entries
217
+ end
218
+
219
+ def comparable_pair?(left, right)
220
+ left.is_a?(ComparableModel) && right.is_a?(ComparableModel) &&
221
+ left.instance_of?(right.class)
222
+ end
223
+
224
+ def differing?(lval, rval)
225
+ if lval.is_a?(::Array) && rval.is_a?(::Array)
226
+ lval.size != rval.size ||
227
+ lval.zip(rval).any? { |l, r| l != r }
228
+ else
229
+ lval != rval
230
+ end
231
+ end
232
+
233
+ def path_string(path)
234
+ path.reduce("") do |acc, seg|
235
+ seg.is_a?(::Integer) ? "#{acc}[#{seg}]" : "#{acc}.#{seg}"
236
+ end.delete_prefix(".")
237
+ end
238
+ end
239
+
148
240
  # DiffContext handles the comparison between two objects
149
241
  class DiffContext
150
242
  attr_reader :obj1, :obj2, :show_unchanged, :highlight_diff, :use_colors
@@ -556,6 +648,49 @@ type_info = nil)
556
648
  end
557
649
  end
558
650
  end
651
+
652
+ private
653
+
654
+ def unordered_eql?(left, right, seen, ignore_order)
655
+ return true if left.equal?(right)
656
+ return false unless left.instance_of?(right.class)
657
+
658
+ seen[left] ||= {}.compare_by_identity
659
+ return true if seen[left][right]
660
+
661
+ seen[left][right] = true
662
+ left.class.attributes.keys.all? do |attr|
663
+ lval = left.public_send(attr)
664
+ rval = right.public_send(attr)
665
+
666
+ if lval.is_a?(ComparableModel) && rval.is_a?(ComparableModel)
667
+ unordered_eql?(lval, rval, seen, ignore_order)
668
+ elsif ignore_order && lval.is_a?(::Array) && rval.is_a?(::Array)
669
+ unordered_items_equal?(lval, rval, seen)
670
+ else
671
+ lval == rval
672
+ end
673
+ end
674
+ end
675
+
676
+ def unordered_items_equal?(lval, rval, seen)
677
+ return false unless lval.size == rval.size
678
+
679
+ unmatched = rval.dup
680
+ lval.all? do |litem|
681
+ idx = unmatched.index do |ritem|
682
+ if litem.is_a?(ComparableModel) && ritem.is_a?(ComparableModel)
683
+ unordered_eql?(litem, ritem, seen, true)
684
+ else
685
+ litem == ritem
686
+ end
687
+ end
688
+ return false unless idx
689
+
690
+ unmatched.delete_at(idx)
691
+ true
692
+ end
693
+ end
559
694
  end
560
695
 
561
696
  # Generates a tree representation of the object
@@ -3,7 +3,11 @@
3
3
  module Lutaml
4
4
  module Model
5
5
  class Serializable
6
+ # ComparableModel is included at the Serialize module level; its
7
+ # ClassMethods (diff_with_score) must extend the class that
8
+ # model classes actually inherit from (lutaml-model#18).
6
9
  include Serialize
10
+ extend ComparableModel::ClassMethods
7
11
  end
8
12
  end
9
13
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.40"
5
+ VERSION = "0.8.41"
6
6
  end
7
7
  end
@@ -11,6 +11,10 @@ module Lutaml
11
11
  # - Content rules -> apply_content_rule
12
12
  # - Raw rules -> apply_raw_rule
13
13
  module RuleApplier
14
+ # The answer is static per rule object; transformations are
15
+ # frozen, so the memo lives here instead — bounded by the
16
+ # distinct rule count (TODO.max-perf/20).
17
+ CUSTOM_ONLY_CACHE = {}.compare_by_identity
14
18
  include SkipLogic
15
19
  include ElementBuilder
16
20
 
@@ -226,8 +230,13 @@ register_id)
226
230
  #
227
231
  # @param rule [CompiledRule] The rule
228
232
  # @return [Boolean] true if custom method only
233
+ # The answer is static per rule object and transformations are
234
+ # frozen — a process-global identity-keyed memo is bounded by
235
+ # the distinct rule count and lives no longer than the rules
236
+ # (240k calls → ~one computation per rule on the ISO-13849
237
+ # serialize profile, TODO.max-perf/20).
229
238
  def custom_method_only?(rule)
230
- rule.custom_method_only?
239
+ CUSTOM_ONLY_CACHE[rule] ||= rule.custom_method_only?
231
240
  end
232
241
 
233
242
  # Extract value for a rule
@@ -262,13 +262,15 @@ module Lutaml
262
262
  # @param model_instance [Object] The model instance
263
263
  # @param options [Hash] Options
264
264
  def apply_ordered_rules(root, model_instance, options)
265
+ # Loop-invariant for the whole ordered walk (see
266
+ # apply_standard_rules): one merge per model, not per rule.
267
+ rule_options = options.merge(current_model: model_instance)
265
268
  apply_rules_in_order(
266
269
  root, model_instance, options,
267
270
  compiled_rules, model_class, register_id
268
271
  ) do |action, rule, value, set_xsi_nil|
269
272
  next if action == :apply_rule && duplicate_element_rules[rule]
270
273
 
271
- rule_options = options.merge(current_model: model_instance)
272
274
  case action
273
275
  when :apply_rule
274
276
  apply_rule(root, rule, model_instance, rule_options, model_class,
@@ -331,11 +333,14 @@ module Lutaml
331
333
  compiled_rules
332
334
  end
333
335
 
336
+ # The merge is loop-invariant (model_instance is fixed for the
337
+ # walk) — hoisted out of the per-rule loop. It was ~220k
338
+ # allocations on the ISO-13849 serialize profile (TODO.max-perf/20).
339
+ rule_options = options.merge(current_model: model_instance)
334
340
  rules.each do |rule|
335
341
  next unless valid_mapping?(rule, options)
336
342
  next if duplicate_element_rules[rule]
337
343
 
338
- rule_options = options.merge(current_model: model_instance)
339
344
  apply_rule(root, rule, model_instance, rule_options, model_class,
340
345
  register_id, register)
341
346
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#17 (order-insensitive equality) and #18 (structured
6
+ # diff), on top of the existing eql?/diff_with_score machinery.
7
+ RSpec.describe "Comparison family" do
8
+ let(:phone_a) { Phone.new(kind: "home", number: "555-0100") }
9
+ let(:phone_b) { Phone.new(kind: "work", number: "555-0177") }
10
+
11
+ before do
12
+ stub_const("Phone", Class.new(Lutaml::Model::Serializable) do
13
+ attribute :kind, :string
14
+ attribute :number, :string
15
+ end)
16
+ stub_const("Person", Class.new(Lutaml::Model::Serializable) do
17
+ attribute :name, :string
18
+ attribute :age, :integer
19
+ attribute :phones, Phone, collection: true
20
+ end)
21
+ end
22
+
23
+ describe "#same_as? (#17)" do
24
+ it "treats reordered collections as equal" do
25
+ a = Person.new(phones: [phone_a, phone_b])
26
+ b = Person.new(phones: [phone_b, phone_a])
27
+ expect(a == b).to be(false)
28
+ expect(a.same_as?(b)).to be(true)
29
+ end
30
+
31
+ it "detects genuinely different collections regardless of order" do
32
+ a = Person.new(phones: [phone_a, phone_b])
33
+ b = Person.new(phones: [phone_a, Phone.new(kind: "fax", number: "9")])
34
+ expect(a.same_as?(b)).to be(false)
35
+ end
36
+
37
+ it "respects strict mode" do
38
+ a = Person.new(phones: [phone_a, phone_b])
39
+ b = Person.new(phones: [phone_b, phone_a])
40
+ expect(a.same_as?(b, ignore_element_order: false)).to be(false)
41
+ end
42
+
43
+ it "compares nested models recursively order-insensitively" do
44
+ box1 = Class.new(Lutaml::Model::Serializable) do
45
+ attribute :people, Person, collection: true
46
+ end
47
+ p1 = Person.new(name: "Ada", phones: [phone_a, phone_b])
48
+ p2 = Person.new(name: "Ada", phones: [phone_b, phone_a])
49
+ expect(box1.new(people: [p1]).same_as?(box1.new(people: [p2]))).to be(true)
50
+ end
51
+ end
52
+
53
+ describe "#diff (#18)" do
54
+ it "returns one entry per differing path" do
55
+ a = Person.new(name: "Ada", age: 36, phones: [phone_a])
56
+ b = Person.new(name: "Ada", age: 37, phones: [phone_b])
57
+ entries = a.diff(b)
58
+
59
+ paths = entries.map(&:path)
60
+ expect(paths).to contain_exactly("age", "phones[0].kind", "phones[0].number")
61
+ end
62
+
63
+ it "reports length differences at the collection level" do
64
+ a = Person.new(phones: [phone_a])
65
+ b = Person.new(phones: [phone_a, phone_b])
66
+ entries = a.diff(b)
67
+
68
+ expect(entries.size).to eq(1)
69
+ expect(entries.first.path).to eq("phones[1]")
70
+ expect(entries.first.right).to eq(phone_b)
71
+ end
72
+
73
+ it "diffs equal objects to nothing" do
74
+ a = Person.new(name: "Ada", age: 36, phones: [phone_a])
75
+ expect(a.diff(a.dup)).to be_empty
76
+ end
77
+ end
78
+
79
+ describe ".diff_with_score (#18, pre-existing)" do
80
+ it "is reachable from model classes" do
81
+ a = Person.new(age: 36)
82
+ b = Person.new(age: 37)
83
+ score, tree = Person.diff_with_score(a, b)
84
+ expect(score).to be_a(Float)
85
+ expect(tree).to include("age")
86
+ end
87
+ end
88
+ 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.40
4
+ version: 0.8.41
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -1711,6 +1711,7 @@ files:
1711
1711
  - spec/lutaml/model/collection_state_nesting_spec.rb
1712
1712
  - spec/lutaml/model/collection_validation_spec.rb
1713
1713
  - spec/lutaml/model/comparable_model_spec.rb
1714
+ - spec/lutaml/model/comparison_family_spec.rb
1714
1715
  - spec/lutaml/model/compiled_rule_spec.rb
1715
1716
  - spec/lutaml/model/consolidation_spec.rb
1716
1717
  - spec/lutaml/model/context_registry_spec.rb