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 +4 -4
- data/lib/lutaml/model/comparable_model.rb +135 -0
- data/lib/lutaml/model/serializable.rb +4 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/transformation/rule_applier.rb +10 -1
- data/lib/lutaml/xml/transformation.rb +7 -2
- data/spec/lutaml/model/comparison_family_spec.rb +88 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6fc47a2bee1b20995c17426890ae3fdc3f9db24317a8cb0c83ea3e81f13372be
|
|
4
|
+
data.tar.gz: 72b52030485d681b901b077b75195a40d7779c0ec12df982e8bc7f49b3ef9dad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/lutaml/model/version.rb
CHANGED
|
@@ -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.
|
|
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
|