rails_console_pro 0.1.0
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 +7 -0
- data/.editorconfig +12 -0
- data/.rspec +4 -0
- data/.rspec_status +240 -0
- data/.rubocop.yml +26 -0
- data/CHANGELOG.md +24 -0
- data/CONTRIBUTING.md +76 -0
- data/LICENSE.txt +22 -0
- data/QUICK_START.md +112 -0
- data/README.md +124 -0
- data/Rakefile +13 -0
- data/config/database.yml +3 -0
- data/docs/ASSOCIATION_NAVIGATION.md +85 -0
- data/docs/EXPORT.md +95 -0
- data/docs/FORMATTING.md +86 -0
- data/docs/MODEL_STATISTICS.md +72 -0
- data/docs/OBJECT_DIFFING.md +87 -0
- data/docs/SCHEMA_INSPECTION.md +60 -0
- data/docs/SQL_EXPLAIN.md +70 -0
- data/lib/generators/rails_console_pro/install_generator.rb +16 -0
- data/lib/generators/rails_console_pro/templates/rails_console_pro.rb +44 -0
- data/lib/rails_console_pro/active_record_extensions.rb +113 -0
- data/lib/rails_console_pro/association_navigator.rb +273 -0
- data/lib/rails_console_pro/base_printer.rb +74 -0
- data/lib/rails_console_pro/color_helper.rb +36 -0
- data/lib/rails_console_pro/commands/base_command.rb +17 -0
- data/lib/rails_console_pro/commands/diff_command.rb +135 -0
- data/lib/rails_console_pro/commands/explain_command.rb +118 -0
- data/lib/rails_console_pro/commands/export_command.rb +16 -0
- data/lib/rails_console_pro/commands/schema_command.rb +20 -0
- data/lib/rails_console_pro/commands/stats_command.rb +93 -0
- data/lib/rails_console_pro/commands.rb +34 -0
- data/lib/rails_console_pro/configuration.rb +219 -0
- data/lib/rails_console_pro/diff_result.rb +56 -0
- data/lib/rails_console_pro/error_handler.rb +60 -0
- data/lib/rails_console_pro/explain_result.rb +47 -0
- data/lib/rails_console_pro/format_exporter.rb +403 -0
- data/lib/rails_console_pro/global_methods.rb +42 -0
- data/lib/rails_console_pro/initializer.rb +176 -0
- data/lib/rails_console_pro/model_validator.rb +219 -0
- data/lib/rails_console_pro/paginator.rb +204 -0
- data/lib/rails_console_pro/printers/active_record_printer.rb +30 -0
- data/lib/rails_console_pro/printers/collection_printer.rb +34 -0
- data/lib/rails_console_pro/printers/diff_printer.rb +97 -0
- data/lib/rails_console_pro/printers/explain_printer.rb +151 -0
- data/lib/rails_console_pro/printers/relation_printer.rb +25 -0
- data/lib/rails_console_pro/printers/schema_printer.rb +188 -0
- data/lib/rails_console_pro/printers/stats_printer.rb +129 -0
- data/lib/rails_console_pro/pry_commands.rb +241 -0
- data/lib/rails_console_pro/pry_integration.rb +9 -0
- data/lib/rails_console_pro/railtie.rb +29 -0
- data/lib/rails_console_pro/schema_inspector_result.rb +43 -0
- data/lib/rails_console_pro/serializers/active_record_serializer.rb +18 -0
- data/lib/rails_console_pro/serializers/array_serializer.rb +31 -0
- data/lib/rails_console_pro/serializers/base_serializer.rb +25 -0
- data/lib/rails_console_pro/serializers/diff_serializer.rb +24 -0
- data/lib/rails_console_pro/serializers/explain_serializer.rb +35 -0
- data/lib/rails_console_pro/serializers/relation_serializer.rb +25 -0
- data/lib/rails_console_pro/serializers/schema_serializer.rb +121 -0
- data/lib/rails_console_pro/serializers/stats_serializer.rb +24 -0
- data/lib/rails_console_pro/services/column_stats_calculator.rb +64 -0
- data/lib/rails_console_pro/services/index_analyzer.rb +110 -0
- data/lib/rails_console_pro/services/stats_calculator.rb +40 -0
- data/lib/rails_console_pro/services/table_size_calculator.rb +43 -0
- data/lib/rails_console_pro/stats_result.rb +66 -0
- data/lib/rails_console_pro/version.rb +6 -0
- data/lib/rails_console_pro.rb +14 -0
- data/lib/tasks/rails_console_pro.rake +10 -0
- data/rails_console_pro.gemspec +60 -0
- metadata +240 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsConsolePro
|
|
4
|
+
# ActiveRecord extensions for export functionality
|
|
5
|
+
module ActiveRecordExtensions
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
module ClassMethods
|
|
9
|
+
# Export schema to file
|
|
10
|
+
def export_schema_to_file(file_path, format: nil)
|
|
11
|
+
result = Commands.schema(self)
|
|
12
|
+
result&.export_to_file(file_path, format: format)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Export schema to JSON
|
|
16
|
+
def schema_to_json(pretty: true)
|
|
17
|
+
result = Commands.schema(self)
|
|
18
|
+
result&.to_json(pretty: pretty)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Export schema to YAML
|
|
22
|
+
def schema_to_yaml
|
|
23
|
+
result = Commands.schema(self)
|
|
24
|
+
result&.to_yaml
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Export schema to HTML
|
|
28
|
+
def schema_to_html(style: :default)
|
|
29
|
+
result = Commands.schema(self)
|
|
30
|
+
result&.to_html(style: style)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Export record to JSON
|
|
35
|
+
def to_json_export(pretty: true)
|
|
36
|
+
FormatExporter.to_json(self, pretty: pretty)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Export record to YAML
|
|
40
|
+
def to_yaml_export
|
|
41
|
+
FormatExporter.to_yaml(self)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Export record to HTML
|
|
45
|
+
def to_html_export(style: :default)
|
|
46
|
+
FormatExporter.to_html(self, title: "#{self.class.name} ##{id}", style: style)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Export record to file
|
|
50
|
+
def export_to_file(file_path, format: nil)
|
|
51
|
+
FormatExporter.export_to_file(self, file_path, format: format)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# ActiveRecord::Relation extensions
|
|
56
|
+
module RelationExtensions
|
|
57
|
+
# Export relation to JSON
|
|
58
|
+
def to_json_export(pretty: true)
|
|
59
|
+
FormatExporter.to_json(self, pretty: pretty)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Export relation to YAML
|
|
63
|
+
def to_yaml_export
|
|
64
|
+
FormatExporter.to_yaml(self)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Export relation to HTML
|
|
68
|
+
def to_html_export(style: :default)
|
|
69
|
+
FormatExporter.to_html(self, title: "#{klass.name} Collection (#{count} records)", style: style)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Export relation to file
|
|
73
|
+
def export_to_file(file_path, format: nil)
|
|
74
|
+
FormatExporter.export_to_file(self, file_path, format: format)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Array extensions for ActiveRecord collections
|
|
79
|
+
module ArrayExtensions
|
|
80
|
+
# Export array to JSON
|
|
81
|
+
def to_json_export(pretty: true)
|
|
82
|
+
FormatExporter.to_json(self, pretty: pretty)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Export array to YAML
|
|
86
|
+
def to_yaml_export
|
|
87
|
+
FormatExporter.to_yaml(self)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Export array to HTML
|
|
91
|
+
def to_html_export(style: :default)
|
|
92
|
+
title = if !empty? && first.is_a?(ActiveRecord::Base)
|
|
93
|
+
"#{first.class.name} Collection (#{size} records)"
|
|
94
|
+
else
|
|
95
|
+
"Array (#{size} items)"
|
|
96
|
+
end
|
|
97
|
+
FormatExporter.to_html(self, title: title, style: style)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Export array to file
|
|
101
|
+
def export_to_file(file_path, format: nil)
|
|
102
|
+
FormatExporter.export_to_file(self, file_path, format: format)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Include extensions in ActiveRecord (only if ActiveRecord is loaded)
|
|
108
|
+
if defined?(ActiveRecord::Base)
|
|
109
|
+
ActiveRecord::Base.include(RailsConsolePro::ActiveRecordExtensions)
|
|
110
|
+
ActiveRecord::Relation.include(RailsConsolePro::RelationExtensions)
|
|
111
|
+
Array.include(RailsConsolePro::ArrayExtensions)
|
|
112
|
+
end
|
|
113
|
+
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsConsolePro
|
|
4
|
+
# Interactive association navigator
|
|
5
|
+
class AssociationNavigator
|
|
6
|
+
include ColorHelper
|
|
7
|
+
|
|
8
|
+
ASSOCIATION_ICONS = {
|
|
9
|
+
belongs_to: "↖️",
|
|
10
|
+
has_one: "→",
|
|
11
|
+
has_many: "⇒",
|
|
12
|
+
has_and_belongs_to_many: "⇔"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :model, :history
|
|
16
|
+
|
|
17
|
+
def initialize(model)
|
|
18
|
+
@model = resolve_model(model)
|
|
19
|
+
@history = []
|
|
20
|
+
validate_model!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def start
|
|
24
|
+
current_model = @model
|
|
25
|
+
loop do
|
|
26
|
+
display_menu(current_model)
|
|
27
|
+
choice = get_user_choice(current_model)
|
|
28
|
+
|
|
29
|
+
break if choice == :exit
|
|
30
|
+
next handle_back(current_model) if choice == :back
|
|
31
|
+
next unless choice
|
|
32
|
+
|
|
33
|
+
@history.push(current_model)
|
|
34
|
+
current_model = navigate_to(current_model, choice)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def resolve_model(model_or_string)
|
|
41
|
+
return model_or_string unless model_or_string.is_a?(String)
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
model_or_string.constantize
|
|
45
|
+
rescue NameError
|
|
46
|
+
raise ArgumentError, "Could not find model '#{model_or_string}'"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_model!
|
|
51
|
+
ModelValidator.validate_model!(@model)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def display_menu(model)
|
|
55
|
+
puts pastel.bright_cyan.bold("\n" + "═" * 70)
|
|
56
|
+
puts pastel.bright_cyan.bold("🧭 ASSOCIATION NAVIGATOR")
|
|
57
|
+
puts pastel.bright_cyan.bold("═" * 70)
|
|
58
|
+
|
|
59
|
+
print_breadcrumb(model)
|
|
60
|
+
print_model_info(model)
|
|
61
|
+
print_associations_menu(model)
|
|
62
|
+
print_navigation_options
|
|
63
|
+
puts pastel.cyan("\n" + "─" * 70)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def print_breadcrumb(model)
|
|
67
|
+
breadcrumb = @history.map(&:name).join(" → ")
|
|
68
|
+
breadcrumb += " → " unless breadcrumb.empty?
|
|
69
|
+
breadcrumb += pastel.bright_green.bold(model.name)
|
|
70
|
+
|
|
71
|
+
puts pastel.yellow.bold("\n📍 Current Location:")
|
|
72
|
+
puts " #{breadcrumb}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def print_model_info(model)
|
|
76
|
+
return unless model.respond_to?(:count)
|
|
77
|
+
|
|
78
|
+
count = model.count rescue "?"
|
|
79
|
+
puts pastel.dim(" (#{count} records in database)")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def print_associations_menu(model)
|
|
83
|
+
associations = get_all_associations(model)
|
|
84
|
+
|
|
85
|
+
if associations.empty?
|
|
86
|
+
puts pastel.red(" No associations found for this model")
|
|
87
|
+
return
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
puts pastel.yellow.bold("\n🔗 Available Associations:")
|
|
91
|
+
@menu_items = []
|
|
92
|
+
|
|
93
|
+
group_associations(associations).each do |macro, group|
|
|
94
|
+
print_association_group(macro, group)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def group_associations(associations)
|
|
99
|
+
associations.group_by(&:macro)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def print_association_group(macro, associations)
|
|
103
|
+
return if associations.empty?
|
|
104
|
+
|
|
105
|
+
puts pastel.cyan("\n #{macro}:")
|
|
106
|
+
associations.each_with_index do |assoc, index|
|
|
107
|
+
menu_index = @menu_items.length + 1
|
|
108
|
+
@menu_items << assoc
|
|
109
|
+
|
|
110
|
+
icon = ASSOCIATION_ICONS[macro] || "•"
|
|
111
|
+
details = format_association_details(assoc, model)
|
|
112
|
+
|
|
113
|
+
puts " #{pastel.bright_blue.bold(menu_index.to_s.rjust(2))}. #{icon} " \
|
|
114
|
+
"#{pastel.white.bold(assoc.name.to_s)} → #{pastel.green(assoc.class_name)}#{details}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def format_association_details(assoc, model)
|
|
119
|
+
details = []
|
|
120
|
+
details << pastel.dim(" [#{assoc.foreign_key}]") if assoc.respond_to?(:foreign_key)
|
|
121
|
+
details << pastel.dim(" (optional)") if assoc.options[:optional]
|
|
122
|
+
details << pastel.yellow(" (#{assoc.options[:dependent]})") if assoc.options[:dependent]
|
|
123
|
+
details << pastel.magenta(" through :#{assoc.options[:through]}") if assoc.options[:through]
|
|
124
|
+
details << pastel.dim(" [#{assoc.join_table}]") if assoc.respond_to?(:join_table) && assoc.join_table
|
|
125
|
+
|
|
126
|
+
# Try to get count for has_many associations
|
|
127
|
+
if assoc.macro == :has_many && model.respond_to?(:first)
|
|
128
|
+
count_str = get_association_count(model, assoc)
|
|
129
|
+
details << count_str if count_str
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
details.join
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def get_association_count(model, assoc)
|
|
136
|
+
return nil unless model.respond_to?(:first)
|
|
137
|
+
|
|
138
|
+
sample = model.first
|
|
139
|
+
return nil unless sample
|
|
140
|
+
|
|
141
|
+
count = sample.send(assoc.name).count rescue nil
|
|
142
|
+
return nil unless count
|
|
143
|
+
|
|
144
|
+
pastel.dim(" [~#{count} per record]")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def print_navigation_options
|
|
148
|
+
puts pastel.yellow.bold("\n📌 Navigation Options:")
|
|
149
|
+
puts " #{pastel.bright_blue.bold('b')} - Go back to previous model" if @history.any?
|
|
150
|
+
puts " #{pastel.bright_blue.bold('q')} - Exit navigator"
|
|
151
|
+
puts " #{pastel.bright_blue.bold('s')} - Show sample records for current model"
|
|
152
|
+
puts " #{pastel.bright_blue.bold('c')} - Show count for all associations"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def get_all_associations(model)
|
|
156
|
+
klass = model.is_a?(Class) ? model : model.klass
|
|
157
|
+
|
|
158
|
+
[:belongs_to, :has_one, :has_many, :has_and_belongs_to_many].flat_map do |macro|
|
|
159
|
+
klass.reflect_on_all_associations(macro)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def get_user_choice(model)
|
|
164
|
+
print pastel.bright_green("\n➤ Enter choice: ")
|
|
165
|
+
input = gets.chomp.downcase.strip
|
|
166
|
+
|
|
167
|
+
return :exit if input == 'q'
|
|
168
|
+
return :back if input == 'b'
|
|
169
|
+
return handle_sample_records(model) if input == 's'
|
|
170
|
+
return handle_association_counts(model) if input == 'c'
|
|
171
|
+
|
|
172
|
+
resolve_choice(input)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def resolve_choice(input)
|
|
176
|
+
if input.match?(/^\d+$/)
|
|
177
|
+
index = input.to_i - 1
|
|
178
|
+
return @menu_items[index] if index >= 0 && index < @menu_items.length
|
|
179
|
+
|
|
180
|
+
puts pastel.red("Invalid choice. Please select a number from the menu.")
|
|
181
|
+
nil
|
|
182
|
+
else
|
|
183
|
+
assoc = @menu_items.find { |a| a.name.to_s == input }
|
|
184
|
+
assoc || (puts pastel.red("Invalid choice.") && nil)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def handle_back(current_model)
|
|
189
|
+
if @history.any?
|
|
190
|
+
@history.pop
|
|
191
|
+
else
|
|
192
|
+
puts pastel.yellow("Already at the starting model")
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def navigate_to(current_model, association)
|
|
197
|
+
klass = current_model.is_a?(Class) ? current_model : current_model.klass
|
|
198
|
+
target_class = association.class_name.constantize
|
|
199
|
+
|
|
200
|
+
puts pastel.green("\n✅ Navigating to #{target_class.name}...")
|
|
201
|
+
target_class
|
|
202
|
+
rescue NameError => e
|
|
203
|
+
puts pastel.red("❌ Error: Could not find model #{association.class_name}")
|
|
204
|
+
puts pastel.yellow(" Make sure the model is loaded and defined.")
|
|
205
|
+
current_model
|
|
206
|
+
rescue => e
|
|
207
|
+
puts pastel.red("❌ Error navigating: #{e.message}")
|
|
208
|
+
current_model
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def handle_sample_records(model)
|
|
212
|
+
klass = model.is_a?(Class) ? model : model.klass
|
|
213
|
+
|
|
214
|
+
puts pastel.bright_magenta.bold("\n📋 Sample Records from #{klass.name}:")
|
|
215
|
+
puts pastel.dim("─" * 60)
|
|
216
|
+
|
|
217
|
+
records = klass.limit(3).to_a
|
|
218
|
+
|
|
219
|
+
if records.empty?
|
|
220
|
+
puts pastel.yellow(" No records found in database")
|
|
221
|
+
else
|
|
222
|
+
records.each_with_index do |record, index|
|
|
223
|
+
puts pastel.cyan("\n Record ##{index + 1} (ID: #{record.id}):")
|
|
224
|
+
display_record_attributes(record)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
puts pastel.dim("─" * 60)
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def display_record_attributes(record)
|
|
233
|
+
display_attrs = record.attributes.first(5)
|
|
234
|
+
display_attrs.each do |key, value|
|
|
235
|
+
value_str = value.nil? ? pastel.dim("nil") : value.to_s.truncate(50)
|
|
236
|
+
puts " #{pastel.blue(key)}: #{pastel.white(value_str)}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
if record.attributes.size > 5
|
|
240
|
+
puts pastel.dim(" ... and #{record.attributes.size - 5} more attributes")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def handle_association_counts(model)
|
|
245
|
+
klass = model.is_a?(Class) ? model : model.klass
|
|
246
|
+
|
|
247
|
+
puts pastel.bright_magenta.bold("\n📊 Association Counts for #{klass.name}:")
|
|
248
|
+
puts pastel.dim("─" * 60)
|
|
249
|
+
|
|
250
|
+
sample = klass.first
|
|
251
|
+
unless sample
|
|
252
|
+
puts pastel.yellow(" No records to analyze")
|
|
253
|
+
return nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
associations = get_all_associations(model)
|
|
257
|
+
associations.each do |assoc|
|
|
258
|
+
print_association_count(sample, assoc)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
puts pastel.dim("─" * 60)
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def print_association_count(sample, assoc)
|
|
266
|
+
count = sample.send(assoc.name).count
|
|
267
|
+
count_str = count == 0 ? pastel.red(count.to_s) : pastel.green(count.to_s)
|
|
268
|
+
puts " #{pastel.blue(assoc.name.to_s.ljust(25))} → #{count_str} records"
|
|
269
|
+
rescue => e
|
|
270
|
+
puts " #{pastel.blue(assoc.name.to_s.ljust(25))} → #{pastel.dim('error loading')}"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsConsolePro
|
|
4
|
+
# Base printer class with common functionality
|
|
5
|
+
class BasePrinter
|
|
6
|
+
include ColorHelper
|
|
7
|
+
|
|
8
|
+
attr_reader :output, :value, :pry_instance
|
|
9
|
+
|
|
10
|
+
def initialize(output, value, pry_instance)
|
|
11
|
+
@output = output
|
|
12
|
+
@value = value
|
|
13
|
+
@pry_instance = pry_instance
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def print
|
|
17
|
+
Pry::ColorPrinter.default(output, value, pry_instance)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
protected
|
|
21
|
+
|
|
22
|
+
# Border helper with caching
|
|
23
|
+
BORDER_CACHE = {}
|
|
24
|
+
private_constant :BORDER_CACHE
|
|
25
|
+
|
|
26
|
+
def border(char = nil, length = nil)
|
|
27
|
+
char ||= config.border_char
|
|
28
|
+
length ||= config.header_width
|
|
29
|
+
cache_key = "#{char}#{length}"
|
|
30
|
+
BORDER_CACHE[cache_key] ||= color(config.get_color(:border), char * length)
|
|
31
|
+
output.puts BORDER_CACHE[cache_key]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def header(title, width = nil)
|
|
35
|
+
width ||= config.header_width
|
|
36
|
+
header_color = config.get_color(:header)
|
|
37
|
+
output.puts color(header_color, "┌─ #{title} " + "─" * (width - title.length - 4))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def footer(width = nil)
|
|
41
|
+
width ||= config.header_width
|
|
42
|
+
footer_color = config.get_color(:footer)
|
|
43
|
+
output.puts color(footer_color, "└" + "─" * width)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Helper for bold colored text (delegates to ColorHelper)
|
|
47
|
+
def bold_color(method, text)
|
|
48
|
+
RailsConsolePro::ColorHelper.pastel.bold.public_send(method, text)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Format value with type-aware coloring
|
|
52
|
+
def format_value(val)
|
|
53
|
+
case val
|
|
54
|
+
when NilClass
|
|
55
|
+
color(config.get_color(:attribute_value_nil), "nil")
|
|
56
|
+
when Numeric
|
|
57
|
+
color(config.get_color(:attribute_value_numeric), val.inspect)
|
|
58
|
+
when TrueClass, FalseClass
|
|
59
|
+
color(config.get_color(:attribute_value_boolean), val.inspect)
|
|
60
|
+
when Time, ActiveSupport::TimeWithZone
|
|
61
|
+
color(config.get_color(:attribute_value_time), val.to_s)
|
|
62
|
+
when String
|
|
63
|
+
color(config.get_color(:attribute_value_string), val.inspect)
|
|
64
|
+
else
|
|
65
|
+
color(config.get_color(:attribute_value_string), val.inspect)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Get configuration instance
|
|
70
|
+
def config
|
|
71
|
+
RailsConsolePro.config
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsConsolePro
|
|
4
|
+
# Color helper module with memoization for performance
|
|
5
|
+
module ColorHelper
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
# Memoized pastel instance
|
|
9
|
+
def pastel
|
|
10
|
+
@pastel ||= PASTEL
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Delegates color methods to pastel with memoization
|
|
14
|
+
def color(method, text)
|
|
15
|
+
pastel.public_send(method, text)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Helper for bold colored text
|
|
19
|
+
def bold_color(method, text)
|
|
20
|
+
pastel.bold.public_send(method, text)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Chainable color methods
|
|
24
|
+
def method_missing(method_name, *args, &block)
|
|
25
|
+
if pastel.respond_to?(method_name)
|
|
26
|
+
pastel.public_send(method_name, *args, &block)
|
|
27
|
+
else
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
33
|
+
pastel.respond_to?(method_name, include_private) || super
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsConsolePro
|
|
4
|
+
module Commands
|
|
5
|
+
# Base class for commands with shared functionality
|
|
6
|
+
class BaseCommand
|
|
7
|
+
include ColorHelper
|
|
8
|
+
|
|
9
|
+
protected
|
|
10
|
+
|
|
11
|
+
def pastel
|
|
12
|
+
ColorHelper.pastel
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsConsolePro
|
|
4
|
+
module Commands
|
|
5
|
+
class DiffCommand < BaseCommand
|
|
6
|
+
def execute(object1, object2)
|
|
7
|
+
return nil if object1.nil? || object2.nil?
|
|
8
|
+
|
|
9
|
+
execute_diff(object1, object2)
|
|
10
|
+
rescue => e
|
|
11
|
+
RailsConsolePro::ErrorHandler.handle(e, context: :diff)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def execute_diff(object1, object2)
|
|
17
|
+
differences = {}
|
|
18
|
+
identical = true
|
|
19
|
+
|
|
20
|
+
# Handle ActiveRecord objects
|
|
21
|
+
if object1.is_a?(ActiveRecord::Base) && object2.is_a?(ActiveRecord::Base)
|
|
22
|
+
differences, identical = diff_active_record_objects(object1, object2)
|
|
23
|
+
# Handle Hash objects
|
|
24
|
+
elsif object1.is_a?(Hash) && object2.is_a?(Hash)
|
|
25
|
+
differences, identical = diff_hashes(object1, object2)
|
|
26
|
+
# Handle plain objects with attributes
|
|
27
|
+
elsif object1.respond_to?(:attributes) && object2.respond_to?(:attributes)
|
|
28
|
+
differences, identical = diff_by_attributes(object1, object2)
|
|
29
|
+
else
|
|
30
|
+
# Simple comparison
|
|
31
|
+
identical = object1 == object2
|
|
32
|
+
differences = identical ? {} : { value: { old_value: object1, new_value: object2 } }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
DiffResult.new(
|
|
36
|
+
object1: object1,
|
|
37
|
+
object2: object2,
|
|
38
|
+
differences: differences,
|
|
39
|
+
identical: identical
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def diff_active_record_objects(object1, object2)
|
|
44
|
+
differences = {}
|
|
45
|
+
identical = true
|
|
46
|
+
|
|
47
|
+
# Get all attributes (including virtual ones)
|
|
48
|
+
all_attrs = (object1.attributes.keys | object2.attributes.keys).uniq
|
|
49
|
+
|
|
50
|
+
all_attrs.each do |attr|
|
|
51
|
+
val1 = object1.read_attribute(attr)
|
|
52
|
+
val2 = object2.read_attribute(attr)
|
|
53
|
+
|
|
54
|
+
if val1 != val2
|
|
55
|
+
identical = false
|
|
56
|
+
differences[attr] = {
|
|
57
|
+
old_value: val1,
|
|
58
|
+
new_value: val2
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
[differences, identical]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def diff_hashes(hash1, hash2)
|
|
67
|
+
differences = {}
|
|
68
|
+
identical = true
|
|
69
|
+
|
|
70
|
+
all_keys = (hash1.keys | hash2.keys).uniq
|
|
71
|
+
|
|
72
|
+
all_keys.each do |key|
|
|
73
|
+
val1 = hash1[key]
|
|
74
|
+
val2 = hash2[key]
|
|
75
|
+
|
|
76
|
+
if val1 != val2
|
|
77
|
+
identical = false
|
|
78
|
+
if hash1.key?(key) && hash2.key?(key)
|
|
79
|
+
differences[key] = {
|
|
80
|
+
old_value: val1,
|
|
81
|
+
new_value: val2
|
|
82
|
+
}
|
|
83
|
+
elsif hash1.key?(key)
|
|
84
|
+
differences[key] = {
|
|
85
|
+
only_in_object1: val1
|
|
86
|
+
}
|
|
87
|
+
else
|
|
88
|
+
differences[key] = {
|
|
89
|
+
only_in_object2: val2
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
[differences, identical]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def diff_by_attributes(object1, object2)
|
|
99
|
+
differences = {}
|
|
100
|
+
identical = true
|
|
101
|
+
|
|
102
|
+
attrs1 = object1.attributes rescue object1.instance_variables.map { |v| v.to_s.delete('@').to_sym }
|
|
103
|
+
attrs2 = object2.attributes rescue object2.instance_variables.map { |v| v.to_s.delete('@').to_sym }
|
|
104
|
+
|
|
105
|
+
all_attrs = (attrs1 | attrs2).uniq
|
|
106
|
+
|
|
107
|
+
all_attrs.each do |attr|
|
|
108
|
+
val1 = object1.respond_to?(attr) ? object1.public_send(attr) : nil
|
|
109
|
+
val2 = object2.respond_to?(attr) ? object2.public_send(attr) : nil
|
|
110
|
+
|
|
111
|
+
if val1 != val2
|
|
112
|
+
identical = false
|
|
113
|
+
if attrs1.include?(attr) && attrs2.include?(attr)
|
|
114
|
+
differences[attr] = {
|
|
115
|
+
old_value: val1,
|
|
116
|
+
new_value: val2
|
|
117
|
+
}
|
|
118
|
+
elsif attrs1.include?(attr)
|
|
119
|
+
differences[attr] = {
|
|
120
|
+
only_in_object1: val1
|
|
121
|
+
}
|
|
122
|
+
else
|
|
123
|
+
differences[attr] = {
|
|
124
|
+
only_in_object2: val2
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
[differences, identical]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|