lutaml-model 0.3.3 → 0.3.4
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/.rubocop_todo.yml +48 -9
- data/README.adoc +295 -55
- data/exe/lutaml-model +7 -0
- data/lib/lutaml/model/cli.rb +131 -0
- data/lib/lutaml/model/comparable_model.rb +528 -0
- data/lib/lutaml/model/comparable_nil.rb +11 -0
- data/lib/lutaml/model/comparison.rb +15 -0
- data/lib/lutaml/model/schema/json_schema.rb +23 -7
- data/lib/lutaml/model/schema/json_schema_parser.rb +91 -0
- data/lib/lutaml/model/schema/relaxng_schema.rb +42 -12
- data/lib/lutaml/model/schema/xsd_schema.rb +29 -11
- data/lib/lutaml/model/schema/yaml_schema.rb +21 -6
- data/lib/lutaml/model/serializable.rb +2 -0
- data/lib/lutaml/model/serialize.rb +23 -7
- data/lib/lutaml/model/type.rb +0 -8
- data/lib/lutaml/model/version.rb +1 -1
- data/lutaml-model.gemspec +2 -5
- metadata +25 -4
@@ -0,0 +1,131 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "thor"
|
4
|
+
require "lutaml/model"
|
5
|
+
|
6
|
+
module Lutaml
|
7
|
+
module Model
|
8
|
+
# Command line interface for detecting duplicate records readable by Lutaml::Model
|
9
|
+
class Cli < Thor
|
10
|
+
# desc "detect-duplicates PATH...",
|
11
|
+
# "Detect duplicate records readable by Lutaml::Model, files or directories"
|
12
|
+
# method_option :show_unchanged, type: :boolean, default: false,
|
13
|
+
# desc: "Show unchanged attributes in the diff output"
|
14
|
+
# method_option :highlight_diff, type: :boolean, default: false,
|
15
|
+
# desc: "Highlight only the differences"
|
16
|
+
# method_option :color, type: :string, enum: %w[auto on off], default: "auto",
|
17
|
+
# desc: "Use colors in the diff output (auto, on, off)"
|
18
|
+
|
19
|
+
# def detect_duplicates(*paths)
|
20
|
+
# all_records = []
|
21
|
+
# paths.each do |path|
|
22
|
+
# if File.directory?(path)
|
23
|
+
# Dir.glob(File.join(path, "*.xml")).each do |file|
|
24
|
+
# process_file(file, all_records)
|
25
|
+
# end
|
26
|
+
# elsif File.file?(path) && path.end_with?(".xml")
|
27
|
+
# process_file(path, all_records)
|
28
|
+
# else
|
29
|
+
# puts "Warning: Skipping invalid path: #{path}"
|
30
|
+
# end
|
31
|
+
# end
|
32
|
+
|
33
|
+
# # TODO: Change using URL to a configurable primary key entered by user
|
34
|
+
# records_by_url = {}
|
35
|
+
# all_records.each do |record|
|
36
|
+
# urls = record[:record].location.flat_map do |loc|
|
37
|
+
# loc.url.map(&:content)
|
38
|
+
# end.compact
|
39
|
+
# unless urls.any?
|
40
|
+
# puts "Warning: Record without URL found in file: #{record[:file]}"
|
41
|
+
# next
|
42
|
+
# end
|
43
|
+
|
44
|
+
# urls.each do |url|
|
45
|
+
# records_by_url[url] ||= []
|
46
|
+
# records_by_url[url] << record
|
47
|
+
# end
|
48
|
+
# end
|
49
|
+
|
50
|
+
# duplicate_count = 0
|
51
|
+
# records_by_url.each do |url, records|
|
52
|
+
# next unless records.size > 1
|
53
|
+
|
54
|
+
# duplicate_count += 1
|
55
|
+
# puts "Duplicate set ##{duplicate_count} found for URL: #{url}"
|
56
|
+
# records.combination(2).each_with_index do |(record1, record2), index|
|
57
|
+
# puts " Comparison #{index + 1}:"
|
58
|
+
# puts " File 1: #{record1[:file]}"
|
59
|
+
# puts " File 2: #{record2[:file]}"
|
60
|
+
# print_differences(
|
61
|
+
# record1[:record],
|
62
|
+
# record2[:record],
|
63
|
+
# options[:show_unchanged],
|
64
|
+
# options[:highlight_diff],
|
65
|
+
# color_enabled?,
|
66
|
+
# )
|
67
|
+
# puts "\n"
|
68
|
+
# end
|
69
|
+
# end
|
70
|
+
# end
|
71
|
+
|
72
|
+
# private
|
73
|
+
|
74
|
+
# def process_file(file, all_records)
|
75
|
+
# xml_content = File.read(file)
|
76
|
+
# collection = Lutaml::Model.from_xml(xml_content)
|
77
|
+
# collection.mods.each do |record|
|
78
|
+
# all_records << { record: record, file: file }
|
79
|
+
# end
|
80
|
+
# end
|
81
|
+
|
82
|
+
# def print_differences(record1, record2, show_unchanged, highlight_diff,
|
83
|
+
# use_colors)
|
84
|
+
# diff_score, diff_tree = Lutaml::Model.diff_with_score(
|
85
|
+
# record1,
|
86
|
+
# record2,
|
87
|
+
# show_unchanged: show_unchanged,
|
88
|
+
# highlight_diff: highlight_diff,
|
89
|
+
# use_colors: use_colors,
|
90
|
+
# indent: " ",
|
91
|
+
# )
|
92
|
+
# similarity_percentage = (1 - diff_score) * 100
|
93
|
+
|
94
|
+
# puts " Differences:"
|
95
|
+
# puts diff_tree
|
96
|
+
# puts " Similarity score: #{similarity_percentage.round(2)}%"
|
97
|
+
# end
|
98
|
+
|
99
|
+
# def color_enabled?
|
100
|
+
# case options[:color]
|
101
|
+
# when "on"
|
102
|
+
# true
|
103
|
+
# when "off"
|
104
|
+
# false
|
105
|
+
# else
|
106
|
+
# supports_color?
|
107
|
+
# end
|
108
|
+
# end
|
109
|
+
|
110
|
+
# def supports_color?
|
111
|
+
# return false unless $stdout.tty?
|
112
|
+
|
113
|
+
# if /mswin|mingw|cygwin/.match?(RbConfig::CONFIG["host_os"])
|
114
|
+
# return true if ENV["ANSICON"]
|
115
|
+
# return true if ENV["ConEmuANSI"] == "ON"
|
116
|
+
# return true if ENV["TERM"] == "xterm"
|
117
|
+
# end
|
118
|
+
|
119
|
+
# return true if ENV["COLORTERM"]
|
120
|
+
|
121
|
+
# term = ENV.fetch("TERM", nil)
|
122
|
+
# return false if term.nil? || term.empty?
|
123
|
+
|
124
|
+
# color_terms = %w[ansi color console cygwin gnome konsole kterm
|
125
|
+
# linux msys putty rxvt screen tmux vt100 xterm]
|
126
|
+
|
127
|
+
# color_terms.any? { |ct| term.include?(ct) }
|
128
|
+
# end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,528 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Lutaml
|
4
|
+
module Model
|
5
|
+
# ComparableModel module provides functionality to compare and diff two objects
|
6
|
+
# of the same class, based on their attribute values.
|
7
|
+
module ComparableModel
|
8
|
+
def self.included(base)
|
9
|
+
base.extend(ClassMethods)
|
10
|
+
end
|
11
|
+
|
12
|
+
# Checks if two objects are equal based on their attributes
|
13
|
+
# @param other [Object] The object to compare with
|
14
|
+
# @return [Boolean] True if objects are equal, false otherwise
|
15
|
+
def eql?(other)
|
16
|
+
other.class == self.class &&
|
17
|
+
self.class.attributes.all? do |attr, _|
|
18
|
+
send(attr) == other.send(attr)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
alias == eql?
|
23
|
+
|
24
|
+
# Generates a hash value for the object
|
25
|
+
# @return [Integer] The hash value
|
26
|
+
def hash
|
27
|
+
([self.class] + self.class.attributes.map do |attr, _|
|
28
|
+
send(attr).hash
|
29
|
+
end).hash
|
30
|
+
end
|
31
|
+
|
32
|
+
# Class methods added to the class that includes ComparableModel
|
33
|
+
module ClassMethods
|
34
|
+
# Generates a diff tree between two objects of the same class
|
35
|
+
# @param obj1 [Object] The first object to compare
|
36
|
+
# @param obj2 [Object] The second object to compare
|
37
|
+
# @param options [Hash] Options for diff generation
|
38
|
+
# @return [String] A string representation of the diff tree
|
39
|
+
def diff_tree
|
40
|
+
if @obj1.nil? && @obj2.nil?
|
41
|
+
@root_tree = Tree.new("Both objects are nil")
|
42
|
+
elsif @obj1.nil?
|
43
|
+
@root_tree = Tree.new("First object is nil")
|
44
|
+
format_single_value(@obj2, @root_tree, @obj2.class.to_s)
|
45
|
+
elsif @obj2.nil?
|
46
|
+
@root_tree = Tree.new("Second object is nil")
|
47
|
+
format_single_value(@obj1, @root_tree, @obj1.class.to_s)
|
48
|
+
else
|
49
|
+
traverse_diff do |name, type, value1, value2, is_last|
|
50
|
+
format_attribute_diff(name, type, value1, value2, is_last)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
@root_tree.to_s
|
55
|
+
end
|
56
|
+
|
57
|
+
# Generates a diff tree and calculates a diff score between two objects of the same class
|
58
|
+
# @param obj1 [Object] The first object to compare
|
59
|
+
# @param obj2 [Object] The second object to compare
|
60
|
+
# @param options [Hash] Options for diff generation
|
61
|
+
# @return [Array<Float, String>] An array containing the normalized diff score and the diff tree
|
62
|
+
def diff_with_score(obj1, obj2, **options)
|
63
|
+
context = DiffContext.new(obj1, obj2, **options)
|
64
|
+
indent = options[:indent] || ""
|
65
|
+
[context.calculate_diff_score, context.diff_tree(indent)]
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
class Tree
|
70
|
+
attr_accessor :content, :children, :color
|
71
|
+
|
72
|
+
def initialize(content, color: nil)
|
73
|
+
@content = content
|
74
|
+
@children = []
|
75
|
+
@color = color
|
76
|
+
end
|
77
|
+
|
78
|
+
def add_child(child)
|
79
|
+
@children << child
|
80
|
+
end
|
81
|
+
|
82
|
+
def to_s(indent = "", is_last = true)
|
83
|
+
prefix = is_last ? "└── " : "├── "
|
84
|
+
result = "#{indent}#{colorize(prefix + @content, @color)}\n"
|
85
|
+
@children.each_with_index do |child, index|
|
86
|
+
is_last_child = index == @children.size - 1
|
87
|
+
child_indent = indent + (if is_last
|
88
|
+
" "
|
89
|
+
else
|
90
|
+
"#{colorize('│',
|
91
|
+
@color)} "
|
92
|
+
end)
|
93
|
+
result += child.to_s(child_indent, is_last_child)
|
94
|
+
end
|
95
|
+
result
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def colorize(text, color)
|
101
|
+
return text unless color
|
102
|
+
|
103
|
+
color_codes = { red: 31, green: 32, blue: 34 }
|
104
|
+
"\e[#{color_codes[color]}m#{text}\e[0m"
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# DiffContext handles the comparison between two objects
|
109
|
+
class DiffContext
|
110
|
+
attr_reader :obj1, :obj2, :show_unchanged, :highlight_diff, :use_colors
|
111
|
+
attr_accessor :level, :tree_lines, :root_tree
|
112
|
+
|
113
|
+
# Initializes a new DiffContext
|
114
|
+
# @param obj1 [Object] The first object to compare
|
115
|
+
# @param obj2 [Object] The second object to compare
|
116
|
+
# @param options [Hash] Options for diff generation
|
117
|
+
def initialize(obj1, obj2, **options)
|
118
|
+
@obj1 = obj1
|
119
|
+
@obj2 = obj2
|
120
|
+
@show_unchanged = options.fetch(:show_unchanged, false)
|
121
|
+
@highlight_diff = options.fetch(:highlight_diff, false)
|
122
|
+
@use_colors = options.fetch(:use_colors, true)
|
123
|
+
@level = 0
|
124
|
+
@tree_lines = []
|
125
|
+
@root_tree = Tree.new(obj1.class.to_s)
|
126
|
+
end
|
127
|
+
|
128
|
+
# Generates a diff tree between the two objects
|
129
|
+
# @return [String] A string representation of the diff tree
|
130
|
+
def diff_tree(indent = "")
|
131
|
+
traverse_diff do |name, type, value1, value2, is_last|
|
132
|
+
format_attribute_diff(name, type, value1, value2, is_last)
|
133
|
+
end
|
134
|
+
@root_tree.to_s(indent)
|
135
|
+
end
|
136
|
+
|
137
|
+
# Calculates the normalized diff score
|
138
|
+
# @return [Float] The normalized diff score
|
139
|
+
def calculate_diff_score
|
140
|
+
total_score = 0
|
141
|
+
total_attributes = 0
|
142
|
+
traverse_diff do |_, _, value1, value2, _|
|
143
|
+
total_score += calculate_attribute_score(value1, value2)
|
144
|
+
total_attributes += 1
|
145
|
+
end
|
146
|
+
total_attributes.positive? ? total_score / total_attributes : 0
|
147
|
+
end
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
# Applies color to text if colors are enabled
|
152
|
+
# @param text [String] The text to color
|
153
|
+
# @param color [Symbol] The color to apply
|
154
|
+
# @return [String] The colored text
|
155
|
+
def colorize(text, color)
|
156
|
+
return text unless @use_colors
|
157
|
+
|
158
|
+
color_codes = { red: 31, green: 32, blue: 34 }
|
159
|
+
"\e[#{color_codes[color]}m#{text}\e[0m"
|
160
|
+
end
|
161
|
+
|
162
|
+
# Traverses the attributes of the objects and yields each attribute's details
|
163
|
+
# @yield [String, Symbol, Object, Object, Boolean] Yields the name, type, value1, value2, and is_last for each attribute
|
164
|
+
def traverse_diff
|
165
|
+
return yield nil, nil, obj1, obj2, true if obj1.class != obj2.class
|
166
|
+
|
167
|
+
obj1.class.attributes.each_with_index do |(name, type), index|
|
168
|
+
yield name, type, obj1.send(name), obj2.send(name), index == obj1.class.attributes.length - 1
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
# Generates the prefix for tree lines
|
173
|
+
# @return [String] Prefix for tree lines
|
174
|
+
def tree_prefix
|
175
|
+
@tree_lines.map { |enabled| enabled ? "│ " : " " }.join
|
176
|
+
end
|
177
|
+
|
178
|
+
# Formats a line in the tree structure
|
179
|
+
# @param is_last [Boolean] Whether this is the last item in the current level
|
180
|
+
# @param content [String] The content to be displayed in the line
|
181
|
+
# @return [String] Formatted tree line
|
182
|
+
def tree_line(is_last, content)
|
183
|
+
"#{tree_prefix}#{is_last ? '└── ' : '├── '}#{content}\n"
|
184
|
+
end
|
185
|
+
|
186
|
+
# Calculates the diff score for a single attribute
|
187
|
+
# @param value1 [Object] The value of the attribute in the first object
|
188
|
+
# @param value2 [Object] The value of the attribute in the second object
|
189
|
+
# @return [Float] The diff score for the attribute
|
190
|
+
def calculate_attribute_score(value1, value2)
|
191
|
+
if value1 == value2
|
192
|
+
0
|
193
|
+
elsif value1.is_a?(Array) && value2.is_a?(Array)
|
194
|
+
calculate_array_score(value1, value2)
|
195
|
+
else
|
196
|
+
value1.instance_of?(value2.class) ? 0.5 : 1
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
# Calculates the diff score for array attributes
|
201
|
+
# @param arr1 [Array] The array from the first object
|
202
|
+
# @param arr2 [Array] The array from the second object
|
203
|
+
# @return [Float] The diff score for the arrays
|
204
|
+
def calculate_array_score(arr1, arr2)
|
205
|
+
max_length = [arr1.length, arr2.length].max
|
206
|
+
return 0.0 if max_length.zero?
|
207
|
+
|
208
|
+
total_score = max_length.times.sum do |i|
|
209
|
+
if i < arr1.length && i < arr2.length
|
210
|
+
if arr1[i] == arr2[i]
|
211
|
+
0.0
|
212
|
+
elsif arr1[i].is_a?(ComparableModel) && arr2[i].is_a?(ComparableModel)
|
213
|
+
DiffContext.new(arr1[i], arr2[i],
|
214
|
+
show_unchanged: @show_unchanged).calculate_diff_score
|
215
|
+
else
|
216
|
+
calculate_attribute_score(arr1[i], arr2[i])
|
217
|
+
end
|
218
|
+
else
|
219
|
+
1.0
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
total_score / max_length
|
224
|
+
end
|
225
|
+
|
226
|
+
# Formats a value for display in the diff output
|
227
|
+
# @param value [Object] The value to format
|
228
|
+
# @return [String] Formatted value
|
229
|
+
def format_value(value)
|
230
|
+
case value
|
231
|
+
when nil
|
232
|
+
"(nil)"
|
233
|
+
when String
|
234
|
+
"(String) \"#{value}\""
|
235
|
+
when Array
|
236
|
+
if value.empty?
|
237
|
+
"(Array) 0 items"
|
238
|
+
else
|
239
|
+
items = value.map { |item| format_value(item) }.join(", ")
|
240
|
+
"(Array) [#{items}]"
|
241
|
+
end
|
242
|
+
when Hash
|
243
|
+
"(Hash) #{value.keys.length} keys"
|
244
|
+
when ComparableModel
|
245
|
+
"(#{value.class})"
|
246
|
+
else
|
247
|
+
"(#{value.class}) #{value}"
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
# Formats the diff output for a single attribute
|
252
|
+
# @param name [String] The name of the attribute
|
253
|
+
# @param type [Symbol] The type of the attribute
|
254
|
+
# @param value1 [Object] The value of the attribute in the first object
|
255
|
+
# @param value2 [Object] The value of the attribute in the second object
|
256
|
+
# @param is_last [Boolean] Whether this is the last attribute in the list
|
257
|
+
# @return [String] Formatted diff output for the attribute
|
258
|
+
def format_attribute_diff(name, type, value1, value2, _is_last)
|
259
|
+
return if value1 == value2 && !@show_unchanged
|
260
|
+
|
261
|
+
node = Tree.new("#{name} (#{obj1.class.attributes[name].collection? ? 'collection' : type_name(type)}):")
|
262
|
+
@root_tree.add_child(node)
|
263
|
+
|
264
|
+
if obj1.class.attributes[name].collection?
|
265
|
+
format_collection(value1, value2, node)
|
266
|
+
elsif value1 == value2
|
267
|
+
format_single_value(value1, node, "")
|
268
|
+
else
|
269
|
+
format_value_tree(value1, value2, node, "", type_name(type))
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
# Formats a collection (array) for diff output
|
274
|
+
# @param array1 [Array] The first array to compare
|
275
|
+
# @param array2 [Array] The second array to compare
|
276
|
+
# @return [String] Formatted diff output for the collection
|
277
|
+
def format_collection(array1, array2, parent_node)
|
278
|
+
array2 = [] if array2.nil?
|
279
|
+
max_length = [array1.length, array2.length].max
|
280
|
+
|
281
|
+
if max_length.zero?
|
282
|
+
parent_node.content += " (nil)"
|
283
|
+
return
|
284
|
+
end
|
285
|
+
|
286
|
+
max_length.times do |index|
|
287
|
+
item1 = array1[index]
|
288
|
+
item2 = array2[index]
|
289
|
+
|
290
|
+
next if item1 == item2 && !@show_unchanged
|
291
|
+
|
292
|
+
prefix = if item2.nil?
|
293
|
+
"- "
|
294
|
+
else
|
295
|
+
(item1.nil? ? "+ " : "")
|
296
|
+
end
|
297
|
+
color = if item2.nil?
|
298
|
+
:red
|
299
|
+
else
|
300
|
+
(item1.nil? ? :green : nil)
|
301
|
+
end
|
302
|
+
type = item1&.class || item2&.class
|
303
|
+
|
304
|
+
node = Tree.new("#{prefix}[#{index + 1}] (#{type_name(type)})",
|
305
|
+
color: color)
|
306
|
+
parent_node.add_child(node)
|
307
|
+
|
308
|
+
if item1.nil?
|
309
|
+
format_diff_item(item2, :green, node)
|
310
|
+
elsif item2.nil?
|
311
|
+
format_diff_item(item1, :red, node)
|
312
|
+
else
|
313
|
+
format_value_tree(item1, item2, node, "")
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
# Formats a removed item in the diff output
|
319
|
+
# @param item [Object] The removed item
|
320
|
+
# @param is_last [Boolean] Whether this is the last item in the current level
|
321
|
+
# @param index [Integer] The index of the removed item
|
322
|
+
# @return [String] Formatted output for the removed item
|
323
|
+
def format_removed_item(item, _parent_node)
|
324
|
+
format_diff_item(item, :red)
|
325
|
+
end
|
326
|
+
|
327
|
+
# Formats an added item in the diff output
|
328
|
+
# @param item [Object] The added item
|
329
|
+
# @param is_last [Boolean] Whether this is the last item in the current level
|
330
|
+
# @param index [Integer] The index of the added item
|
331
|
+
# @return [String] Formatted output for the added item
|
332
|
+
def format_added_item(item, _parent_node)
|
333
|
+
format_diff_item(item, :green)
|
334
|
+
end
|
335
|
+
|
336
|
+
# Formats a diff item (added or removed)
|
337
|
+
# @param item [Object] The item to format
|
338
|
+
# @param is_last [Boolean] Whether this is the last item in the current level
|
339
|
+
# @param index [Integer] The index of the item
|
340
|
+
# @param color [Symbol] The color to use for the item
|
341
|
+
# @param prefix [String] The prefix to use for the item (+ or -)
|
342
|
+
# @return [String] Formatted output for the diff item
|
343
|
+
def format_diff_item(item, color, parent_node)
|
344
|
+
if item.is_a?(ComparableModel)
|
345
|
+
return format_comparable_mapper(item, parent_node, color)
|
346
|
+
end
|
347
|
+
|
348
|
+
parent_node.add_child(Tree.new(format_value(item), color: color))
|
349
|
+
end
|
350
|
+
|
351
|
+
# Formats the content of an object for diff output
|
352
|
+
# @param obj [Object] The object to format
|
353
|
+
# @return [String] Formatted content of the object
|
354
|
+
def format_object_content(obj)
|
355
|
+
return format_value(obj) unless obj.is_a?(ComparableModel)
|
356
|
+
|
357
|
+
obj.class.attributes.map do |attr, _|
|
358
|
+
"#{attr}: #{format_value(obj.send(attr))}"
|
359
|
+
end.join("\n")
|
360
|
+
end
|
361
|
+
|
362
|
+
# Formats and colors the content for diff output
|
363
|
+
# @param content [String] The content to format and color
|
364
|
+
# @param color [Symbol] The color to apply
|
365
|
+
# @param is_last [Boolean] Whether this is the last item in the current level
|
366
|
+
# @return [String] Formatted and colored content
|
367
|
+
def format_colored_content(content, color, is_last)
|
368
|
+
lines = content.split("\n")
|
369
|
+
lines.map.with_index do |line, index|
|
370
|
+
if index.zero?
|
371
|
+
"" # Skip the first line as it's already been output
|
372
|
+
else
|
373
|
+
prefix = index == lines.length - 1 && is_last ? "└── " : "├── "
|
374
|
+
tree_line(index == lines.length - 1 && is_last,
|
375
|
+
colorize("#{prefix}#{line}", color))
|
376
|
+
end
|
377
|
+
end.join
|
378
|
+
end
|
379
|
+
|
380
|
+
# Gets the name of a type
|
381
|
+
# @param type [Class, Object] The type to get the name for
|
382
|
+
# @return [String] The name of the type
|
383
|
+
def type_name(type)
|
384
|
+
if type.is_a?(Class)
|
385
|
+
type.name
|
386
|
+
elsif type.respond_to?(:type)
|
387
|
+
type.type.name
|
388
|
+
else
|
389
|
+
type.class.name
|
390
|
+
end
|
391
|
+
end
|
392
|
+
|
393
|
+
# Formats the attributes of an object for diff output
|
394
|
+
# @param obj1 [Object] The first object
|
395
|
+
# @param obj2 [Object] The second object
|
396
|
+
# @return [String] Formatted attributes of the objects
|
397
|
+
def format_object_attributes(obj1, obj2, parent_node)
|
398
|
+
obj1.class.attributes.each_key do |attr|
|
399
|
+
value1 = obj1.send(attr)
|
400
|
+
value2 = obj2&.send(attr)
|
401
|
+
|
402
|
+
attr_type = obj1.class.attributes[attr].collection? ? "collection" : type_name(obj1.class.attributes[attr])
|
403
|
+
|
404
|
+
if value1 == value2
|
405
|
+
if @show_unchanged
|
406
|
+
format_single_value(value1, parent_node,
|
407
|
+
"#{attr} (#{attr_type})")
|
408
|
+
end
|
409
|
+
else
|
410
|
+
format_value_tree(value1, value2, parent_node, attr, attr_type)
|
411
|
+
end
|
412
|
+
end
|
413
|
+
end
|
414
|
+
|
415
|
+
# Formats the value tree for diff output
|
416
|
+
# @param value1 [Object] The first value
|
417
|
+
# @param value2 [Object] The second value
|
418
|
+
# @param is_last [Boolean] Whether this is the last item in the current level
|
419
|
+
# @param label [String] The label for the value
|
420
|
+
# @param type_info [String, nil] Additional type information
|
421
|
+
# @return [String] Formatted value tree
|
422
|
+
def format_value_tree(value1, value2, parent_node, label,
|
423
|
+
type_info = nil)
|
424
|
+
return if value1 == value2 && !@show_unchanged
|
425
|
+
|
426
|
+
if value1 == value2
|
427
|
+
if @show_unchanged
|
428
|
+
return format_single_value(
|
429
|
+
value1,
|
430
|
+
parent_node,
|
431
|
+
"#{label}#{type_info ? " (#{type_info})" : ''}",
|
432
|
+
)
|
433
|
+
end
|
434
|
+
|
435
|
+
return if @highlight_diff
|
436
|
+
end
|
437
|
+
|
438
|
+
case value1
|
439
|
+
when Array
|
440
|
+
format_collection(value1, value2, parent_node)
|
441
|
+
when Hash
|
442
|
+
format_hash_tree(value1, value2, parent_node)
|
443
|
+
when ComparableModel
|
444
|
+
format_object_attributes(value1, value2, parent_node)
|
445
|
+
else
|
446
|
+
node = Tree.new("#{label}#{type_info ? " (#{type_info})" : ''}:")
|
447
|
+
parent_node.add_child(node)
|
448
|
+
node.add_child(Tree.new("- #{format_value(value1)}", color: :red))
|
449
|
+
node.add_child(Tree.new("+ #{format_value(value2)}", color: :green))
|
450
|
+
end
|
451
|
+
end
|
452
|
+
|
453
|
+
# Formats a single value for diff output
|
454
|
+
# @param value [Object] The value to format
|
455
|
+
# @param is_last [Boolean] Whether this is the last item in the current level
|
456
|
+
# @param label [String] The label for the value
|
457
|
+
# @return [String] Formatted single value
|
458
|
+
def format_single_value(value, parent_node, label, color = nil)
|
459
|
+
node = Tree.new("#{label}#{label.empty? ? '' : ':'}", color: color)
|
460
|
+
parent_node.add_child(node)
|
461
|
+
|
462
|
+
case value
|
463
|
+
when ComparableModel
|
464
|
+
format_comparable_mapper(value, node, color)
|
465
|
+
when Array
|
466
|
+
if value.empty?
|
467
|
+
node.add_child(Tree.new("(nil)", color: color))
|
468
|
+
else
|
469
|
+
format_collection(value, value, node)
|
470
|
+
end
|
471
|
+
else
|
472
|
+
node.content += " #{format_value(value)}"
|
473
|
+
end
|
474
|
+
end
|
475
|
+
|
476
|
+
# Formats a ComparableModel object for diff output
|
477
|
+
# @param obj [ComparableModel] The object to format
|
478
|
+
# @return [String] Formatted ComparableModel object
|
479
|
+
def format_comparable_mapper(obj, parent_node, color = nil)
|
480
|
+
obj.class.attributes.each do |attr_name, attr_type|
|
481
|
+
attr_value = obj.send(attr_name)
|
482
|
+
attr_node = Tree.new("#{attr_name} (#{type_name(attr_type)}):",
|
483
|
+
color: color)
|
484
|
+
parent_node.add_child(attr_node)
|
485
|
+
if attr_value.is_a?(ComparableModel)
|
486
|
+
format_comparable_mapper(attr_value, attr_node, color)
|
487
|
+
else
|
488
|
+
value_node = Tree.new(format_value(attr_value), color: color)
|
489
|
+
attr_node.add_child(value_node)
|
490
|
+
end
|
491
|
+
end
|
492
|
+
end
|
493
|
+
|
494
|
+
# Formats a hash tree for diff output
|
495
|
+
# @param hash1 [Hash] The first hash to compare
|
496
|
+
# @param hash2 [Hash] The second hash to compare
|
497
|
+
# @return [String] Formatted hash tree
|
498
|
+
def format_hash_tree(hash1, hash2, parent_node)
|
499
|
+
keys = (hash1.keys + hash2.keys).uniq
|
500
|
+
keys.each do |key|
|
501
|
+
value1 = hash1[key]
|
502
|
+
value2 = hash2[key]
|
503
|
+
|
504
|
+
if value1 == value2
|
505
|
+
format_single_value(value1, parent_node, key) if @show_unchanged
|
506
|
+
else
|
507
|
+
format_value_tree(value1, value2, parent_node, key)
|
508
|
+
end
|
509
|
+
end
|
510
|
+
end
|
511
|
+
end
|
512
|
+
end
|
513
|
+
|
514
|
+
# Generates a tree representation of the object
|
515
|
+
# @return [String] A string representation of the object's attribute tree
|
516
|
+
def to_tree
|
517
|
+
output = "#{self.class}\n"
|
518
|
+
self.class.attributes.each_with_index do |(name, type), index|
|
519
|
+
value = send(name)
|
520
|
+
is_last = index == self.class.attributes.length - 1
|
521
|
+
context = DiffContext.new(nil, nil, show_unchanged: false)
|
522
|
+
formatted = context.format_value(value)
|
523
|
+
output << context.tree_line(is_last, "#{name} (#{type}): #{formatted}")
|
524
|
+
end
|
525
|
+
output
|
526
|
+
end
|
527
|
+
end
|
528
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Lutaml
|
4
|
+
module Model
|
5
|
+
# Comparison of two values for ComparableMapper
|
6
|
+
class Comparison
|
7
|
+
attr_accessor :original, :updated
|
8
|
+
|
9
|
+
def initialize(original:, updated:)
|
10
|
+
@original = original
|
11
|
+
@updated = updated
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|