activerecord-graph-extractor 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/.rspec +4 -0
- data/CHANGELOG.md +36 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +201 -0
- data/LICENSE +21 -0
- data/README.md +532 -0
- data/Rakefile +36 -0
- data/activerecord-graph-extractor.gemspec +64 -0
- data/docs/dry_run.md +410 -0
- data/docs/examples.md +239 -0
- data/docs/s3_integration.md +381 -0
- data/docs/usage.md +363 -0
- data/examples/dry_run_example.rb +227 -0
- data/examples/s3_example.rb +247 -0
- data/exe/arge +7 -0
- data/lib/activerecord_graph_extractor/cli.rb +627 -0
- data/lib/activerecord_graph_extractor/configuration.rb +98 -0
- data/lib/activerecord_graph_extractor/dependency_resolver.rb +406 -0
- data/lib/activerecord_graph_extractor/dry_run_analyzer.rb +421 -0
- data/lib/activerecord_graph_extractor/errors.rb +33 -0
- data/lib/activerecord_graph_extractor/extractor.rb +182 -0
- data/lib/activerecord_graph_extractor/importer.rb +260 -0
- data/lib/activerecord_graph_extractor/json_serializer.rb +176 -0
- data/lib/activerecord_graph_extractor/primary_key_mapper.rb +57 -0
- data/lib/activerecord_graph_extractor/progress_tracker.rb +202 -0
- data/lib/activerecord_graph_extractor/relationship_analyzer.rb +212 -0
- data/lib/activerecord_graph_extractor/s3_client.rb +170 -0
- data/lib/activerecord_graph_extractor/version.rb +5 -0
- data/lib/activerecord_graph_extractor.rb +34 -0
- data/scripts/verify_installation.rb +192 -0
- metadata +388 -0
@@ -0,0 +1,627 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'thor'
|
4
|
+
require 'tty-progressbar'
|
5
|
+
require 'tty-spinner'
|
6
|
+
require 'tty-tree'
|
7
|
+
require 'pastel'
|
8
|
+
require 'tty-prompt'
|
9
|
+
|
10
|
+
module ActiveRecordGraphExtractor
|
11
|
+
class CLI < Thor
|
12
|
+
include Thor::Actions
|
13
|
+
|
14
|
+
def self.exit_on_failure?
|
15
|
+
true
|
16
|
+
end
|
17
|
+
|
18
|
+
desc "version", "Show version information"
|
19
|
+
def version
|
20
|
+
puts "ActiveRecord Graph Extractor v#{ActiveRecordGraphExtractor::VERSION}"
|
21
|
+
end
|
22
|
+
|
23
|
+
desc "extract MODEL_CLASS ID", "Extract a record and its relationships to JSON"
|
24
|
+
option :output, aliases: :o, required: true, desc: "Output file path"
|
25
|
+
option :max_depth, type: :numeric, default: 5, desc: "Maximum relationship depth"
|
26
|
+
option :include_relationships, type: :array, desc: "Specific relationships to include"
|
27
|
+
option :exclude_relationships, type: :array, desc: "Relationships to exclude"
|
28
|
+
option :include_models, type: :array, desc: "Specific models to include"
|
29
|
+
option :exclude_models, type: :array, desc: "Models to exclude"
|
30
|
+
option :batch_size, type: :numeric, default: 1000, desc: "Batch size for processing"
|
31
|
+
option :progress, type: :boolean, default: false, desc: "Show progress visualization"
|
32
|
+
option :show_graph, type: :boolean, default: false, desc: "Show dependency graph"
|
33
|
+
option :stream, type: :boolean, default: false, desc: "Use streaming JSON for large datasets"
|
34
|
+
|
35
|
+
def extract(model_class_name, id)
|
36
|
+
setup_colors
|
37
|
+
|
38
|
+
begin
|
39
|
+
model_class = model_class_name.constantize
|
40
|
+
object = model_class.find(id)
|
41
|
+
rescue NameError
|
42
|
+
error_exit("Model #{model_class_name} not found")
|
43
|
+
rescue ActiveRecord::RecordNotFound
|
44
|
+
error_exit("#{model_class_name} with ID #{id} not found")
|
45
|
+
end
|
46
|
+
|
47
|
+
config = build_config_from_options(options)
|
48
|
+
|
49
|
+
if options[:progress]
|
50
|
+
config[:on_progress] = method(:handle_progress_update)
|
51
|
+
setup_progress_bars
|
52
|
+
end
|
53
|
+
|
54
|
+
@pastel.bright_blue("🔍 Analyzing relationships...")
|
55
|
+
puts
|
56
|
+
|
57
|
+
extractor = Extractor.new(root_object: object, config: config)
|
58
|
+
|
59
|
+
if options[:show_graph]
|
60
|
+
show_dependency_graph(extractor)
|
61
|
+
end
|
62
|
+
|
63
|
+
result = nil
|
64
|
+
with_spinner("Extracting records") do |spinner|
|
65
|
+
result = extractor.extract_to_file(options[:output])
|
66
|
+
spinner.success("(#{result.duration_human})")
|
67
|
+
end
|
68
|
+
|
69
|
+
print_extraction_summary(result)
|
70
|
+
|
71
|
+
rescue => e
|
72
|
+
error_exit("Extraction failed: #{e.message}")
|
73
|
+
end
|
74
|
+
|
75
|
+
desc "import FILE", "Import records from a JSON file"
|
76
|
+
option :batch_size, type: :numeric, default: 1000, desc: "Batch size for processing"
|
77
|
+
option :skip_validations, type: :boolean, default: false, desc: "Skip ActiveRecord validations"
|
78
|
+
option :dry_run, type: :boolean, default: false, desc: "Preview import without saving"
|
79
|
+
option :progress, type: :boolean, default: false, desc: "Show progress visualization"
|
80
|
+
option :show_graph, type: :boolean, default: false, desc: "Show dependency graph during import"
|
81
|
+
|
82
|
+
def import(file_path)
|
83
|
+
setup_colors
|
84
|
+
|
85
|
+
unless File.exist?(file_path)
|
86
|
+
error_exit("File not found: #{file_path}")
|
87
|
+
end
|
88
|
+
|
89
|
+
config = build_config_from_options(options)
|
90
|
+
|
91
|
+
if options[:progress]
|
92
|
+
config[:on_progress] = method(:handle_import_progress_update)
|
93
|
+
setup_import_progress_bars
|
94
|
+
end
|
95
|
+
|
96
|
+
importer = Importer.new(config: config)
|
97
|
+
|
98
|
+
result = nil
|
99
|
+
if options[:dry_run]
|
100
|
+
@pastel.yellow("🔍 Performing dry run...")
|
101
|
+
puts
|
102
|
+
else
|
103
|
+
@pastel.bright_blue("📦 Importing records...")
|
104
|
+
puts
|
105
|
+
end
|
106
|
+
|
107
|
+
with_spinner("Processing import") do |spinner|
|
108
|
+
result = importer.import_from_file(file_path)
|
109
|
+
spinner.success("(#{result.duration_human})")
|
110
|
+
end
|
111
|
+
|
112
|
+
print_import_summary(result)
|
113
|
+
|
114
|
+
rescue => e
|
115
|
+
error_exit("Import failed: #{e.message}")
|
116
|
+
end
|
117
|
+
|
118
|
+
desc "analyze FILE", "Analyze a JSON export file"
|
119
|
+
def analyze(file_path)
|
120
|
+
setup_colors
|
121
|
+
|
122
|
+
unless File.exist?(file_path)
|
123
|
+
error_exit("File not found: #{file_path}")
|
124
|
+
end
|
125
|
+
|
126
|
+
serializer = JSONSerializer.new
|
127
|
+
|
128
|
+
data = nil
|
129
|
+
with_spinner("Loading file") do |spinner|
|
130
|
+
data = serializer.deserialize_from_file(file_path)
|
131
|
+
spinner.success
|
132
|
+
end
|
133
|
+
|
134
|
+
print_analysis(data, file_path)
|
135
|
+
end
|
136
|
+
|
137
|
+
desc "extract_to_s3 MODEL_CLASS ID", "Extract a record and upload directly to S3"
|
138
|
+
option :bucket, aliases: :b, required: true, desc: "S3 bucket name"
|
139
|
+
option :key, aliases: :k, desc: "S3 object key (auto-generated if not provided)"
|
140
|
+
option :region, default: 'us-east-1', desc: "AWS region"
|
141
|
+
option :max_depth, type: :numeric, default: 5, desc: "Maximum relationship depth"
|
142
|
+
option :include_relationships, type: :array, desc: "Specific relationships to include"
|
143
|
+
option :exclude_relationships, type: :array, desc: "Relationships to exclude"
|
144
|
+
option :include_models, type: :array, desc: "Specific models to include"
|
145
|
+
option :exclude_models, type: :array, desc: "Models to exclude"
|
146
|
+
option :progress, type: :boolean, default: false, desc: "Show progress visualization"
|
147
|
+
|
148
|
+
def extract_to_s3(model_class_name, id)
|
149
|
+
setup_colors
|
150
|
+
|
151
|
+
begin
|
152
|
+
model_class = model_class_name.constantize
|
153
|
+
object = model_class.find(id)
|
154
|
+
rescue NameError
|
155
|
+
error_exit("Model #{model_class_name} not found")
|
156
|
+
rescue ActiveRecord::RecordNotFound
|
157
|
+
error_exit("#{model_class_name} with ID #{id} not found")
|
158
|
+
end
|
159
|
+
|
160
|
+
@pastel.bright_blue("🔍 Extracting and uploading to S3...")
|
161
|
+
puts
|
162
|
+
|
163
|
+
extractor = Extractor.new
|
164
|
+
extraction_options = build_extraction_options_from_options(options)
|
165
|
+
|
166
|
+
result = nil
|
167
|
+
with_spinner("Extracting and uploading") do |spinner|
|
168
|
+
result = extractor.extract_and_upload_to_s3(
|
169
|
+
object,
|
170
|
+
bucket_name: options[:bucket],
|
171
|
+
s3_key: options[:key],
|
172
|
+
region: options[:region],
|
173
|
+
options: extraction_options
|
174
|
+
)
|
175
|
+
spinner.success
|
176
|
+
end
|
177
|
+
|
178
|
+
print_s3_extraction_summary(result)
|
179
|
+
|
180
|
+
rescue => e
|
181
|
+
error_exit("S3 extraction failed: #{e.message}")
|
182
|
+
end
|
183
|
+
|
184
|
+
desc "s3_list", "List extraction files in S3 bucket"
|
185
|
+
option :bucket, aliases: :b, required: true, desc: "S3 bucket name"
|
186
|
+
option :prefix, aliases: :p, desc: "S3 key prefix to filter results"
|
187
|
+
option :region, default: 'us-east-1', desc: "AWS region"
|
188
|
+
option :max_keys, type: :numeric, default: 50, desc: "Maximum number of files to list"
|
189
|
+
|
190
|
+
def s3_list
|
191
|
+
setup_colors
|
192
|
+
|
193
|
+
@pastel.bright_blue("📋 Listing S3 files...")
|
194
|
+
puts
|
195
|
+
|
196
|
+
s3_client = S3Client.new(bucket_name: options[:bucket], region: options[:region])
|
197
|
+
|
198
|
+
files = nil
|
199
|
+
with_spinner("Fetching file list") do |spinner|
|
200
|
+
files = s3_client.list_files(
|
201
|
+
prefix: options[:prefix],
|
202
|
+
max_keys: options[:max_keys]
|
203
|
+
)
|
204
|
+
spinner.success
|
205
|
+
end
|
206
|
+
|
207
|
+
print_s3_file_list(files)
|
208
|
+
|
209
|
+
rescue => e
|
210
|
+
error_exit("S3 list failed: #{e.message}")
|
211
|
+
end
|
212
|
+
|
213
|
+
desc "s3_download S3_KEY", "Download an extraction file from S3"
|
214
|
+
option :bucket, aliases: :b, required: true, desc: "S3 bucket name"
|
215
|
+
option :output, aliases: :o, desc: "Local output file path"
|
216
|
+
option :region, default: 'us-east-1', desc: "AWS region"
|
217
|
+
|
218
|
+
def s3_download(s3_key)
|
219
|
+
setup_colors
|
220
|
+
|
221
|
+
@pastel.bright_blue("⬇️ Downloading from S3...")
|
222
|
+
puts
|
223
|
+
|
224
|
+
s3_client = S3Client.new(bucket_name: options[:bucket], region: options[:region])
|
225
|
+
|
226
|
+
result = nil
|
227
|
+
with_spinner("Downloading file") do |spinner|
|
228
|
+
result = s3_client.download_file(s3_key, options[:output])
|
229
|
+
spinner.success
|
230
|
+
end
|
231
|
+
|
232
|
+
print_s3_download_summary(result)
|
233
|
+
|
234
|
+
rescue => e
|
235
|
+
error_exit("S3 download failed: #{e.message}")
|
236
|
+
end
|
237
|
+
|
238
|
+
desc "dry_run MODEL_CLASS ID", "Analyze what would be extracted without performing the actual extraction"
|
239
|
+
option :max_depth, type: :numeric, desc: "Maximum relationship depth to analyze"
|
240
|
+
option :output, aliases: :o, desc: "Output file for analysis report (JSON format)"
|
241
|
+
|
242
|
+
def dry_run(model_class_name, id)
|
243
|
+
setup_colors
|
244
|
+
|
245
|
+
@pastel.bright_blue("🔍 Performing dry run analysis...")
|
246
|
+
puts
|
247
|
+
puts " Model: #{@pastel.cyan(model_class_name)}"
|
248
|
+
puts " ID: #{@pastel.cyan(id)}"
|
249
|
+
puts " Max Depth: #{@pastel.cyan(options[:max_depth] || 'default')}"
|
250
|
+
puts
|
251
|
+
|
252
|
+
begin
|
253
|
+
model_class = model_class_name.constantize
|
254
|
+
record = model_class.find(id)
|
255
|
+
|
256
|
+
extraction_options = build_extraction_options_from_options(options)
|
257
|
+
|
258
|
+
extractor = Extractor.new
|
259
|
+
analysis = nil
|
260
|
+
|
261
|
+
with_spinner("Analyzing object graph") do |spinner|
|
262
|
+
analysis = extractor.dry_run(record, extraction_options)
|
263
|
+
spinner.success
|
264
|
+
end
|
265
|
+
|
266
|
+
if options[:output]
|
267
|
+
File.write(options[:output], JSON.pretty_generate(analysis))
|
268
|
+
puts @pastel.green("📄 Analysis report saved to: #{options[:output]}")
|
269
|
+
puts
|
270
|
+
end
|
271
|
+
|
272
|
+
print_dry_run_analysis(analysis)
|
273
|
+
|
274
|
+
rescue NameError => e
|
275
|
+
error_exit("Model not found: #{model_class_name}. Make sure the model class exists and is loaded.")
|
276
|
+
rescue ActiveRecord::RecordNotFound => e
|
277
|
+
error_exit("Record not found: #{model_class_name} with ID #{id}")
|
278
|
+
rescue => e
|
279
|
+
error_exit("Analysis failed: #{e.message}")
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
private
|
284
|
+
|
285
|
+
def setup_colors
|
286
|
+
@pastel = Pastel.new
|
287
|
+
end
|
288
|
+
|
289
|
+
def build_config_from_options(options)
|
290
|
+
config = {}
|
291
|
+
|
292
|
+
config[:max_depth] = options[:max_depth] if options[:max_depth]
|
293
|
+
config[:include_relationships] = options[:include_relationships] if options[:include_relationships]
|
294
|
+
config[:exclude_relationships] = options[:exclude_relationships] if options[:exclude_relationships]
|
295
|
+
config[:include_models] = options[:include_models] if options[:include_models]
|
296
|
+
config[:exclude_models] = options[:exclude_models] if options[:exclude_models]
|
297
|
+
config[:batch_size] = options[:batch_size] if options[:batch_size]
|
298
|
+
config[:stream_json] = options[:stream] if options.key?(:stream)
|
299
|
+
config[:skip_validations] = options[:skip_validations] if options.key?(:skip_validations)
|
300
|
+
config[:dry_run] = options[:dry_run] if options.key?(:dry_run)
|
301
|
+
|
302
|
+
config
|
303
|
+
end
|
304
|
+
|
305
|
+
def setup_progress_bars
|
306
|
+
@model_progress_bars = {}
|
307
|
+
@main_progress_bar = nil
|
308
|
+
end
|
309
|
+
|
310
|
+
def setup_import_progress_bars
|
311
|
+
@import_progress_bars = {}
|
312
|
+
@main_import_progress_bar = nil
|
313
|
+
end
|
314
|
+
|
315
|
+
def handle_progress_update(stats)
|
316
|
+
model = stats[:model]
|
317
|
+
phase = stats[:phase]
|
318
|
+
|
319
|
+
# Update main progress bar
|
320
|
+
if @main_progress_bar
|
321
|
+
@main_progress_bar.current = stats[:current]
|
322
|
+
@main_progress_bar.total = stats[:total] if stats[:total] > 0
|
323
|
+
elsif stats[:total] && stats[:total] > 0
|
324
|
+
@main_progress_bar = TTY::ProgressBar.new(
|
325
|
+
"#{@pastel.bright_blue('Overall')} [:bar] :percent :current/:total (:rate/s) :eta",
|
326
|
+
total: stats[:total],
|
327
|
+
bar_format: :block
|
328
|
+
)
|
329
|
+
end
|
330
|
+
|
331
|
+
# Update model-specific progress bar
|
332
|
+
if model && stats[:model_progress] && stats[:model_progress][model]
|
333
|
+
model_stats = stats[:model_progress][model]
|
334
|
+
|
335
|
+
unless @model_progress_bars[model]
|
336
|
+
if model_stats[:total] > 0
|
337
|
+
color = get_model_color(model)
|
338
|
+
@model_progress_bars[model] = TTY::ProgressBar.new(
|
339
|
+
"#{@pastel.decorate(model.ljust(12), color)} [:bar] :percent (:current/:total)",
|
340
|
+
total: model_stats[:total],
|
341
|
+
bar_format: :block
|
342
|
+
)
|
343
|
+
end
|
344
|
+
end
|
345
|
+
|
346
|
+
if @model_progress_bars[model]
|
347
|
+
@model_progress_bars[model].current = model_stats[:current]
|
348
|
+
|
349
|
+
if stats[:completed]
|
350
|
+
@model_progress_bars[model].finish
|
351
|
+
@model_progress_bars[model] = @pastel.green("#{model.ljust(12)} ✅ Complete")
|
352
|
+
end
|
353
|
+
end
|
354
|
+
end
|
355
|
+
end
|
356
|
+
|
357
|
+
def handle_import_progress_update(stats)
|
358
|
+
phase = stats[:phase]
|
359
|
+
|
360
|
+
case phase
|
361
|
+
when "Importing records"
|
362
|
+
handle_progress_update(stats)
|
363
|
+
else
|
364
|
+
# Handle other phases
|
365
|
+
if stats[:total] && stats[:total] > 0
|
366
|
+
percentage = (stats[:current].to_f / stats[:total] * 100).round(1)
|
367
|
+
print "\r#{@pastel.bright_blue(phase)}: #{percentage}%"
|
368
|
+
end
|
369
|
+
end
|
370
|
+
end
|
371
|
+
|
372
|
+
def get_model_color(model)
|
373
|
+
colors = [:cyan, :magenta, :yellow, :green, :red, :blue]
|
374
|
+
colors[model.hash % colors.size]
|
375
|
+
end
|
376
|
+
|
377
|
+
def show_dependency_graph(extractor)
|
378
|
+
# This would require access to the dependency graph from the extractor
|
379
|
+
# For now, show a simple tree structure
|
380
|
+
puts @pastel.bright_blue("📊 Dependency Analysis")
|
381
|
+
puts
|
382
|
+
|
383
|
+
# Sample tree structure - in real implementation, this would be built from actual data
|
384
|
+
tree_data = {
|
385
|
+
"Order (root)" => {
|
386
|
+
"User" => {},
|
387
|
+
"Partner" => {},
|
388
|
+
"Products" => {
|
389
|
+
"Photos" => {},
|
390
|
+
"Categories" => {}
|
391
|
+
},
|
392
|
+
"Address" => {}
|
393
|
+
}
|
394
|
+
}
|
395
|
+
|
396
|
+
tree = TTY::Tree.new(tree_data)
|
397
|
+
puts tree.render
|
398
|
+
puts
|
399
|
+
end
|
400
|
+
|
401
|
+
def with_spinner(message)
|
402
|
+
spinner = TTY::Spinner.new("[:spinner] #{message}...", format: :dots)
|
403
|
+
spinner.auto_spin
|
404
|
+
|
405
|
+
result = yield(spinner)
|
406
|
+
|
407
|
+
spinner.stop
|
408
|
+
result
|
409
|
+
end
|
410
|
+
|
411
|
+
def print_extraction_summary(result)
|
412
|
+
puts
|
413
|
+
puts @pastel.bright_green("✅ Extraction completed successfully!")
|
414
|
+
puts
|
415
|
+
puts "📊 " + @pastel.bold("Summary:")
|
416
|
+
puts " Total records: #{@pastel.cyan(result.total_records)}"
|
417
|
+
puts " Models: #{@pastel.cyan(result.models.join(', '))}"
|
418
|
+
puts " File size: #{@pastel.cyan(result.file_size_human)}"
|
419
|
+
puts " Duration: #{@pastel.cyan(result.duration_human)}"
|
420
|
+
puts " Output: #{@pastel.cyan(result.file_path)}"
|
421
|
+
puts
|
422
|
+
end
|
423
|
+
|
424
|
+
def print_import_summary(result)
|
425
|
+
puts
|
426
|
+
|
427
|
+
if result.dry_run
|
428
|
+
puts @pastel.bright_yellow("🔍 Dry run completed successfully!")
|
429
|
+
else
|
430
|
+
puts @pastel.bright_green("✅ Import completed successfully!")
|
431
|
+
end
|
432
|
+
|
433
|
+
puts
|
434
|
+
puts "📊 " + @pastel.bold("Summary:")
|
435
|
+
puts " Total records: #{@pastel.cyan(result.total_records)}"
|
436
|
+
puts " Models imported: #{@pastel.cyan(result.models_imported.join(', '))}"
|
437
|
+
puts " Duration: #{@pastel.cyan(result.duration_human)}"
|
438
|
+
puts " Speed: #{@pastel.cyan("#{result.records_per_second} records/sec")}"
|
439
|
+
|
440
|
+
if result.mapping_statistics
|
441
|
+
puts " ID mappings: #{@pastel.cyan(result.mapping_statistics[:total_mappings])}"
|
442
|
+
end
|
443
|
+
|
444
|
+
puts
|
445
|
+
end
|
446
|
+
|
447
|
+
def print_analysis(data, file_path)
|
448
|
+
metadata = data['metadata']
|
449
|
+
records = data['records']
|
450
|
+
|
451
|
+
puts
|
452
|
+
puts @pastel.bright_blue("📋 File Analysis: #{File.basename(file_path)}")
|
453
|
+
puts
|
454
|
+
|
455
|
+
puts @pastel.bold("Metadata:")
|
456
|
+
puts " Root model: #{@pastel.cyan(metadata['root_model'])}"
|
457
|
+
puts " Root ID: #{@pastel.cyan(metadata['root_id'])}"
|
458
|
+
puts " Extracted: #{@pastel.cyan(metadata['extracted_at'])}"
|
459
|
+
puts " Schema version: #{@pastel.cyan(metadata['schema_version'])}"
|
460
|
+
puts " Total records: #{@pastel.cyan(metadata['total_records'])}"
|
461
|
+
puts
|
462
|
+
|
463
|
+
puts @pastel.bold("Models and record counts:")
|
464
|
+
metadata['model_counts'].each do |model, count|
|
465
|
+
puts " #{model.ljust(20)}: #{@pastel.cyan(count)}"
|
466
|
+
end
|
467
|
+
puts
|
468
|
+
|
469
|
+
file_size = File.size(file_path)
|
470
|
+
if file_size < 1024 * 1024
|
471
|
+
size_human = "#{(file_size / 1024.0).round(1)} KB"
|
472
|
+
else
|
473
|
+
size_human = "#{(file_size / (1024.0 * 1024)).round(1)} MB"
|
474
|
+
end
|
475
|
+
|
476
|
+
puts @pastel.bold("File information:")
|
477
|
+
puts " Size: #{@pastel.cyan(size_human)}"
|
478
|
+
puts " Path: #{@pastel.cyan(file_path)}"
|
479
|
+
puts
|
480
|
+
end
|
481
|
+
|
482
|
+
def build_extraction_options_from_options(options)
|
483
|
+
extraction_options = {}
|
484
|
+
extraction_options[:max_depth] = options[:max_depth] if options[:max_depth]
|
485
|
+
extraction_options[:include_relationships] = options[:include_relationships] if options[:include_relationships]
|
486
|
+
extraction_options[:exclude_relationships] = options[:exclude_relationships] if options[:exclude_relationships]
|
487
|
+
extraction_options[:include_models] = options[:include_models] if options[:include_models]
|
488
|
+
extraction_options[:exclude_models] = options[:exclude_models] if options[:exclude_models]
|
489
|
+
extraction_options
|
490
|
+
end
|
491
|
+
|
492
|
+
def print_s3_extraction_summary(result)
|
493
|
+
puts
|
494
|
+
puts @pastel.bright_green("✅ S3 extraction completed successfully!")
|
495
|
+
puts
|
496
|
+
puts "📊 " + @pastel.bold("Summary:")
|
497
|
+
puts " Total records: #{@pastel.cyan(result['metadata']['total_records'])}"
|
498
|
+
puts " Models: #{@pastel.cyan(result['metadata']['models_extracted'].join(', '))}"
|
499
|
+
puts " Duration: #{@pastel.cyan("#{result['metadata']['duration_seconds']}s")}"
|
500
|
+
puts
|
501
|
+
puts "☁️ " + @pastel.bold("S3 Upload:")
|
502
|
+
puts " Bucket: #{@pastel.cyan(result['s3_upload'][:bucket])}"
|
503
|
+
puts " Key: #{@pastel.cyan(result['s3_upload'][:key])}"
|
504
|
+
puts " Size: #{@pastel.cyan(format_file_size(result['s3_upload'][:size]))}"
|
505
|
+
puts " URL: #{@pastel.cyan(result['s3_upload'][:url])}"
|
506
|
+
puts
|
507
|
+
end
|
508
|
+
|
509
|
+
def print_s3_file_list(files)
|
510
|
+
puts
|
511
|
+
if files.empty?
|
512
|
+
puts @pastel.yellow("No files found")
|
513
|
+
return
|
514
|
+
end
|
515
|
+
|
516
|
+
puts @pastel.bright_blue("📋 S3 Files (#{files.size} found):")
|
517
|
+
puts
|
518
|
+
|
519
|
+
files.each do |file|
|
520
|
+
puts "#{@pastel.cyan(file[:key])}"
|
521
|
+
puts " Size: #{@pastel.dim(format_file_size(file[:size]))}"
|
522
|
+
puts " Modified: #{@pastel.dim(file[:last_modified].strftime('%Y-%m-%d %H:%M:%S'))}"
|
523
|
+
puts
|
524
|
+
end
|
525
|
+
end
|
526
|
+
|
527
|
+
def print_s3_download_summary(result)
|
528
|
+
puts
|
529
|
+
puts @pastel.bright_green("✅ S3 download completed successfully!")
|
530
|
+
puts
|
531
|
+
puts "📊 " + @pastel.bold("Summary:")
|
532
|
+
puts " S3 Key: #{@pastel.cyan(result[:key])}"
|
533
|
+
puts " Local Path: #{@pastel.cyan(result[:local_path])}"
|
534
|
+
puts " Size: #{@pastel.cyan(format_file_size(result[:size]))}"
|
535
|
+
puts
|
536
|
+
end
|
537
|
+
|
538
|
+
def format_file_size(size_bytes)
|
539
|
+
if size_bytes < 1024
|
540
|
+
"#{size_bytes} B"
|
541
|
+
elsif size_bytes < 1024 * 1024
|
542
|
+
"#{(size_bytes / 1024.0).round(1)} KB"
|
543
|
+
elsif size_bytes < 1024 * 1024 * 1024
|
544
|
+
"#{(size_bytes / (1024.0 * 1024)).round(1)} MB"
|
545
|
+
else
|
546
|
+
"#{(size_bytes / (1024.0 * 1024 * 1024)).round(1)} GB"
|
547
|
+
end
|
548
|
+
end
|
549
|
+
|
550
|
+
def print_dry_run_analysis(analysis)
|
551
|
+
puts
|
552
|
+
puts @pastel.bright_green("✅ Dry run analysis completed!")
|
553
|
+
puts
|
554
|
+
|
555
|
+
# Basic info
|
556
|
+
puts "📊 " + @pastel.bold("Analysis Summary:")
|
557
|
+
puts " Analysis time: #{@pastel.cyan("#{analysis['analysis_time']} seconds")}"
|
558
|
+
puts " Root objects: #{@pastel.cyan(analysis['root_objects']['count'])}"
|
559
|
+
puts " Models involved: #{@pastel.cyan(analysis['extraction_scope']['total_models'])}"
|
560
|
+
puts " Total estimated records: #{@pastel.cyan(analysis['extraction_scope']['total_estimated_records'].to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse)}"
|
561
|
+
puts " Estimated file size: #{@pastel.cyan(analysis['estimated_file_size']['human_readable'])}"
|
562
|
+
puts
|
563
|
+
|
564
|
+
# Performance estimates
|
565
|
+
perf = analysis['performance_estimates']
|
566
|
+
puts "⏱️ " + @pastel.bold("Performance Estimates:")
|
567
|
+
puts " Extraction time: #{@pastel.cyan(perf['estimated_extraction_time_human'])}"
|
568
|
+
puts " Memory usage: #{@pastel.cyan(perf['estimated_memory_usage_human'])}"
|
569
|
+
puts
|
570
|
+
|
571
|
+
# Model breakdown
|
572
|
+
puts "📋 " + @pastel.bold("Records by Model:")
|
573
|
+
analysis['estimated_counts_by_model'].each do |model, count|
|
574
|
+
percentage = (count.to_f / analysis['extraction_scope']['total_estimated_records'] * 100).round(1)
|
575
|
+
puts " #{model.ljust(20)} #{@pastel.cyan(count.to_s.rjust(8))} (#{percentage}%)"
|
576
|
+
end
|
577
|
+
puts
|
578
|
+
|
579
|
+
# Depth analysis
|
580
|
+
if analysis['depth_analysis'].any?
|
581
|
+
puts "🌳 " + @pastel.bold("Depth Analysis:")
|
582
|
+
analysis['depth_analysis'].each do |depth, models|
|
583
|
+
puts " Level #{depth}: #{@pastel.cyan(models.join(', '))}"
|
584
|
+
end
|
585
|
+
puts
|
586
|
+
end
|
587
|
+
|
588
|
+
# Warnings
|
589
|
+
if analysis['warnings'].any?
|
590
|
+
puts "⚠️ " + @pastel.bold("Warnings:")
|
591
|
+
analysis['warnings'].each do |warning|
|
592
|
+
color = case warning['severity']
|
593
|
+
when 'high' then :red
|
594
|
+
when 'medium' then :yellow
|
595
|
+
else :white
|
596
|
+
end
|
597
|
+
puts " #{@pastel.decorate("#{warning['type'].upcase}:", color)} #{warning['message']}"
|
598
|
+
end
|
599
|
+
puts
|
600
|
+
end
|
601
|
+
|
602
|
+
# Recommendations
|
603
|
+
if analysis['recommendations'].any?
|
604
|
+
puts "💡 " + @pastel.bold("Recommendations:")
|
605
|
+
analysis['recommendations'].each do |rec|
|
606
|
+
puts " #{@pastel.yellow("#{rec['type'].upcase}:")} #{rec['message']}"
|
607
|
+
puts " #{@pastel.dim("→ #{rec['action']}")}"
|
608
|
+
puts
|
609
|
+
end
|
610
|
+
end
|
611
|
+
|
612
|
+
# Circular references
|
613
|
+
if analysis['relationship_analysis']['circular_references_count'] > 0
|
614
|
+
puts "🔄 " + @pastel.bold("Circular References Detected:")
|
615
|
+
analysis['relationship_analysis']['circular_references'].each do |ref|
|
616
|
+
puts " #{@pastel.yellow(ref['path'])} (depth #{ref['depth']})"
|
617
|
+
end
|
618
|
+
puts
|
619
|
+
end
|
620
|
+
end
|
621
|
+
|
622
|
+
def error_exit(message)
|
623
|
+
puts @pastel.red("❌ Error: #{message}")
|
624
|
+
exit(1)
|
625
|
+
end
|
626
|
+
end
|
627
|
+
end
|