structured_data_to_sql 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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +9 -0
  3. data/bin/structured-data-to-sql +6 -0
  4. data/lib/structured_data_to_sql/cli.rb +405 -0
  5. data/lib/structured_data_to_sql/conversion_result.rb +94 -0
  6. data/lib/structured_data_to_sql/diagnostic.rb +82 -0
  7. data/lib/structured_data_to_sql/diagnostics_report.rb +187 -0
  8. data/lib/structured_data_to_sql/errors.rb +48 -0
  9. data/lib/structured_data_to_sql/format.rb +41 -0
  10. data/lib/structured_data_to_sql/io_support.rb +154 -0
  11. data/lib/structured_data_to_sql/json/exporter_manifest.rb +127 -0
  12. data/lib/structured_data_to_sql/json/json_schema_loader.rb +310 -0
  13. data/lib/structured_data_to_sql/json/profiles/khoros_api_export.rb +1960 -0
  14. data/lib/structured_data_to_sql/json/profiles.rb +14 -0
  15. data/lib/structured_data_to_sql/json/record_streamer.rb +477 -0
  16. data/lib/structured_data_to_sql/json/schema_inferrer.rb +150 -0
  17. data/lib/structured_data_to_sql/json/shredder.rb +241 -0
  18. data/lib/structured_data_to_sql/json/sql_emitter.rb +198 -0
  19. data/lib/structured_data_to_sql/json_converter.rb +913 -0
  20. data/lib/structured_data_to_sql/mysql_dump_xml/invalid_character_report.rb +82 -0
  21. data/lib/structured_data_to_sql/mysql_dump_xml/sanitizer.rb +152 -0
  22. data/lib/structured_data_to_sql/mysql_dump_xml/sax_parser.rb +111 -0
  23. data/lib/structured_data_to_sql/mysql_dump_xml/sql_emitter.rb +104 -0
  24. data/lib/structured_data_to_sql/mysql_dump_xml/table_data_filter.rb +343 -0
  25. data/lib/structured_data_to_sql/mysql_dump_xml/table_discovery.rb +651 -0
  26. data/lib/structured_data_to_sql/mysql_dump_xml/table_structure.rb +98 -0
  27. data/lib/structured_data_to_sql/mysql_dump_xml_converter.rb +4 -0
  28. data/lib/structured_data_to_sql/options.rb +89 -0
  29. data/lib/structured_data_to_sql/progress_reporter.rb +348 -0
  30. data/lib/structured_data_to_sql/sql_text.rb +29 -0
  31. data/lib/structured_data_to_sql/version.rb +5 -0
  32. data/lib/structured_data_to_sql/xml_converter.rb +651 -0
  33. data/lib/structured_data_to_sql/xml_dump_converter.rb +4 -0
  34. data/lib/structured_data_to_sql.rb +54 -0
  35. metadata +120 -0
@@ -0,0 +1,651 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "shellwords"
5
+ require "time"
6
+ require "zlib"
7
+ require_relative "errors"
8
+ require_relative "format"
9
+ require_relative "sql_text"
10
+ require_relative "conversion_result"
11
+ require_relative "io_support"
12
+ require_relative "options"
13
+ require_relative "mysql_dump_xml/table_structure"
14
+ require_relative "mysql_dump_xml/sax_parser"
15
+ require_relative "mysql_dump_xml/sanitizer"
16
+ require_relative "mysql_dump_xml/table_data_filter"
17
+ require_relative "mysql_dump_xml/invalid_character_report"
18
+ require_relative "mysql_dump_xml/sql_emitter"
19
+ require_relative "mysql_dump_xml/table_discovery"
20
+ require_relative "diagnostic"
21
+ require_relative "diagnostics_report"
22
+
23
+ module StructuredDataToSql
24
+ SQLXML = SqlText
25
+
26
+ class XmlConverter
27
+ attr_reader :stats
28
+
29
+ UNESCAPED_AMP = /&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/
30
+ XML_CHUNK_SIZE = 64 * 1024
31
+ XML_SANITIZER_TAIL = 128
32
+ DEFAULT_PROGRESS_INTERVAL = 5
33
+ ZERO_PROGRESS_WARNING_BYTES = 64 * 1024 * 1024
34
+
35
+ def initialize(
36
+ batch_size: 1000,
37
+ include_tables: nil,
38
+ exclude_tables: nil,
39
+ include_files: nil,
40
+ exclude_files: nil,
41
+ schema_only: false,
42
+ scrub_invalid_xml_chars: true,
43
+ invalid_xml_report_path: nil,
44
+ zero_progress_warning_bytes: ZERO_PROGRESS_WARNING_BYTES,
45
+ verbose: false,
46
+ input_gzip: false,
47
+ output_gzip: nil,
48
+ diagnostic_io: nil,
49
+ diagnostics_report: nil,
50
+ **unknown
51
+ )
52
+ if unknown.any?
53
+ raise ConfigurationError,
54
+ "Unknown XML options: #{unknown.keys.join(", ")}"
55
+ end
56
+ XmlOptions.new(batch_size:, verbose:, input_gzip:, output_gzip:)
57
+ unless batch_size.to_i.positive?
58
+ raise UsageError, "XML batch size must be greater than 0"
59
+ end
60
+
61
+ @batch_size = batch_size
62
+ @include_tables = include_tables&.to_set
63
+ @exclude_tables = Array(exclude_tables).to_set
64
+ @include_files = include_files&.to_set
65
+ @exclude_files = Array(exclude_files).to_set
66
+ @schema_only = schema_only
67
+ @verbose = verbose
68
+ @scrub_invalid_xml_chars = scrub_invalid_xml_chars
69
+ @invalid_xml_report_path = invalid_xml_report_path
70
+ @zero_progress_warning_bytes = zero_progress_warning_bytes
71
+ @input_gzip = input_gzip
72
+ @output_gzip = output_gzip
73
+ @diagnostic_io = diagnostic_io
74
+ @diagnostics_report = diagnostics_report
75
+ @sql_emitter = MysqlDumpXml::SqlEmitter.new
76
+ reset_stats
77
+ rescue StructuredDataToSql::ConfigurationError => e
78
+ raise MysqlDumpXml::ConfigurationError, e.message
79
+ end
80
+
81
+ def convert(
82
+ *arguments,
83
+ source: nil,
84
+ output: nil,
85
+ file_pattern: "*.xml",
86
+ progress_callback: nil,
87
+ on_progress: nil,
88
+ atomic: true,
89
+ progress_interval: DEFAULT_PROGRESS_INTERVAL,
90
+ input_gzip: @input_gzip,
91
+ output_gzip: @output_gzip
92
+ )
93
+ source ||= arguments[0]
94
+ output ||= arguments[1]
95
+ raise ConfigurationError, "source is required" if source.nil?
96
+ raise ConfigurationError, "output is required" if output.nil?
97
+ IOSupport.validate_output_target!(output)
98
+
99
+ reset_stats
100
+ @progress_callback = progress_callback
101
+ @on_progress = on_progress
102
+ @progress_interval = progress_interval
103
+ @last_progress_at = nil
104
+ @current_file_progress = nil
105
+ @current_xml_table = nil
106
+ @current_xml_table_included = nil
107
+ @conversion_started_at = Time.now
108
+ @total_input_bytes = 0
109
+ @invalid_xml_report = nil
110
+ @invalid_xml_report_finalized = false
111
+ source_label =
112
+ (
113
+ if IOSupport.readable_io?(source)
114
+ "(IO)"
115
+ else
116
+ Array(source).map(&:to_s).join(", ")
117
+ end
118
+ )
119
+ @source_path = source_label
120
+ @zero_progress_warning_emitted = false
121
+ output_path = IOSupport.writable_io?(output) ? output : Pathname(output)
122
+ unless IOSupport.writable_io?(output)
123
+ prepare_invalid_xml_report(source_label, output_path)
124
+ end
125
+ files, temporary_inputs =
126
+ IOSupport.discover(
127
+ source,
128
+ patterns: [file_pattern, "#{file_pattern}.gz"].uniq,
129
+ stream_extension: ".xml",
130
+ input_gzip:
131
+ )
132
+ raise UsageError, "No XML files found in #{source}" if files.empty?
133
+
134
+ files_to_process, skipped = files.partition { |file| process_file?(file) }
135
+ @stats[:files_skipped] += skipped.length
136
+ if files_to_process.empty?
137
+ raise UsageError,
138
+ "No XML files to process after filtering. All #{files.length} files were excluded."
139
+ end
140
+
141
+ log "\n#{"=" * 60}\nXML TO SQL CONVERTER\n#{"=" * 60}"
142
+ log "\nSource: #{source_label}"
143
+ log "Output: #{output_path}"
144
+ log "Files found: #{files.length}"
145
+ log "Files to skip: #{skipped.length}" if skipped.any?
146
+ log "Files to process: #{files_to_process.length}"
147
+ @total_input_bytes =
148
+ files_to_process.sum do |file|
149
+ begin
150
+ file.size
151
+ rescue StandardError
152
+ 0
153
+ end
154
+ end
155
+ report_progress(
156
+ :start,
157
+ file_count: files_to_process.length,
158
+ total_input_bytes: @total_input_bytes,
159
+ output_path: output_path.to_s,
160
+ force: true
161
+ )
162
+
163
+ gzip_output =
164
+ (
165
+ if output_gzip.nil?
166
+ (!IOSupport.writable_io?(output) && output.to_s.end_with?(".gz"))
167
+ else
168
+ output_gzip
169
+ end
170
+ )
171
+ written_bytes =
172
+ IOSupport.with_output(output, gzip: gzip_output, atomic:) do |out|
173
+ @sql_emitter.write_header(out, source_label: source_label)
174
+ files_to_process.each_with_index do |file, index|
175
+ convert_file(
176
+ file,
177
+ out,
178
+ index: index + 1,
179
+ count: files_to_process.length
180
+ )
181
+ end
182
+ @sql_emitter.write_footer(out)
183
+ end
184
+ @stats[:bytes_written] = written_bytes
185
+ write_diagnostics_report(source_label, output_path)
186
+ report_progress(
187
+ :complete,
188
+ bytes_written: @stats[:bytes_written],
189
+ diagnostics_report: @stats[:diagnostics_report],
190
+ force: true
191
+ )
192
+ finalize_invalid_xml_report(success: true)
193
+ log "\n#{"=" * 60}\nCONVERSION COMPLETE\n#{"=" * 60}"
194
+ log "\nFiles processed: #{@stats[:files_processed]}"
195
+ if @stats[:files_skipped].positive?
196
+ log "Files skipped: #{@stats[:files_skipped]}"
197
+ end
198
+ log "Tables converted: #{@stats[:tables_processed]}"
199
+ if @stats[:tables_skipped].positive?
200
+ log "Tables skipped: #{@stats[:tables_skipped]}"
201
+ end
202
+ log "Rows converted: #{@stats[:rows_processed]}"
203
+ if @stats[:invalid_xml_chars_removed].positive?
204
+ log "Invalid XML chars removed: #{@stats[:invalid_xml_chars_removed]}"
205
+ log "Invalid XML audit: #{@invalid_xml_report.summary_path}"
206
+ end
207
+ log "Output size: #{Format.format_size(@stats[:bytes_written])}"
208
+ log "\nOutput written to: #{output_path}"
209
+ ConversionResult.new(format: :xml, metrics: @stats)
210
+ rescue StructuredDataToSql::InputError => e
211
+ raise MysqlDumpXml::InputError, e.message
212
+ rescue StructuredDataToSql::OutputError => e
213
+ raise MysqlDumpXml::OutputError, e.message
214
+ rescue Nokogiri::XML::SyntaxError => e
215
+ raise MysqlDumpXml::ParseError, e.message
216
+ rescue SystemCallError, IOError, Zlib::Error => e
217
+ raise InputError, e.message
218
+ ensure
219
+ finalize_invalid_xml_report(success: false)
220
+ temporary_inputs&.each(&:unlink)
221
+ @progress_callback = nil
222
+ @on_progress = nil
223
+ @current_file_progress = nil
224
+ @current_xml_table = nil
225
+ @current_xml_table_included = nil
226
+ @invalid_xml_report = nil
227
+ end
228
+
229
+ def convert_file(xml_path, output, index: 1, count: 1)
230
+ log "\nProcessing: #{File.basename(xml_path)}"
231
+ file_size =
232
+ begin
233
+ xml_path.size
234
+ rescue StandardError
235
+ 0
236
+ end
237
+ file_started_at = Time.now
238
+ rows_before = @stats[:rows_processed]
239
+ @current_file_progress = {
240
+ path: xml_path.to_s,
241
+ name: File.basename(xml_path),
242
+ index: index,
243
+ count: count,
244
+ size: file_size,
245
+ bytes_read: 0,
246
+ pass: 1,
247
+ passes: 1
248
+ }
249
+ report_progress(:file_start, force: true)
250
+ structures = {}
251
+ current_rows = []
252
+ current_table = nil
253
+ current_column_formats = []
254
+
255
+ parse_xml_file(xml_path) do |event_type, table, data|
256
+ case event_type
257
+ when :structure
258
+ set_current_xml_table(table)
259
+ @stats[:tables_observed] += 1
260
+ unless process_table?(table)
261
+ @stats[:tables_skipped] += 1
262
+ next
263
+ end
264
+
265
+ if current_rows.any?
266
+ @sql_emitter.write_rows_batch(
267
+ output,
268
+ current_table,
269
+ current_rows,
270
+ column_formats: current_column_formats
271
+ )
272
+ current_rows = []
273
+ end
274
+ structures[table] = data
275
+ output.write("\n-- Table: #{table}\n")
276
+ output.write(data.to_create_table_sql)
277
+ output.write("\n")
278
+ @stats[:tables_processed] += 1
279
+ report_progress(
280
+ :table,
281
+ current_table: table,
282
+ force: @stats[:tables_processed] == 1
283
+ )
284
+ when :row
285
+ @stats[:rows_observed] += 1
286
+ next unless process_table?(table)
287
+ next if @schema_only
288
+
289
+ if current_table != table
290
+ if current_rows.any?
291
+ @sql_emitter.write_rows_batch(
292
+ output,
293
+ current_table,
294
+ current_rows,
295
+ column_formats: current_column_formats
296
+ )
297
+ end
298
+ current_table = table
299
+ current_rows = []
300
+ current_column_formats =
301
+ @sql_emitter.column_formats_for(structures[table], data)
302
+ end
303
+
304
+ current_rows << data
305
+ @stats[:rows_processed] += 1
306
+ report_progress(
307
+ :rows,
308
+ current_table: table,
309
+ force: @stats[:rows_processed] == 1
310
+ )
311
+ if current_rows.length >= @batch_size
312
+ @sql_emitter.write_rows_batch(
313
+ output,
314
+ current_table,
315
+ current_rows,
316
+ column_formats: current_column_formats
317
+ )
318
+ current_rows = []
319
+ end
320
+ end
321
+ end
322
+
323
+ if current_rows.any?
324
+ @sql_emitter.write_rows_batch(
325
+ output,
326
+ current_table,
327
+ current_rows,
328
+ column_formats: current_column_formats
329
+ )
330
+ end
331
+ @stats[:files_processed] += 1
332
+ ledger_entry = {
333
+ name: File.basename(xml_path),
334
+ size: file_size,
335
+ rows: @stats[:rows_processed] - rows_before,
336
+ child_rows: 0,
337
+ elapsed: Time.now - file_started_at
338
+ }
339
+ @ledger << ledger_entry
340
+ report_progress(
341
+ :file_complete,
342
+ file_rows: ledger_entry[:rows],
343
+ file_elapsed: ledger_entry[:elapsed],
344
+ force: true
345
+ )
346
+ @current_file_progress = nil
347
+ end
348
+
349
+ private
350
+
351
+ def parse_xml_file(xml_path, &block)
352
+ parse_xml_file_with_nokogiri(xml_path, &block)
353
+ end
354
+
355
+ def parse_xml_file_with_nokogiri(xml_path, &block)
356
+ parser = MysqlDumpXml::SaxParser.new(&block)
357
+ stream_fixed_xml(xml_path) { |chunk| parser << chunk }
358
+ parser.finish
359
+ end
360
+
361
+ def stream_fixed_xml(path)
362
+ sanitizer =
363
+ MysqlDumpXml::Sanitizer.new(
364
+ path: path.to_s,
365
+ scrub_invalid_xml_chars: @scrub_invalid_xml_chars,
366
+ invalid_xml_handler: method(:record_invalid_xml_char)
367
+ )
368
+ table_data_filter =
369
+ MysqlDumpXml::TableDataFilter.new(
370
+ path: path.to_s,
371
+ table_included: method(:process_table?),
372
+ event_handler: method(:handle_table_data_filter_event),
373
+ skipped_byte_handler:
374
+ proc do |bytes|
375
+ sanitizer.skip_input_bytes(bytes)
376
+ track_excluded_table_data_bytes(bytes)
377
+ end
378
+ )
379
+ open_input(path) do |input|
380
+ while (chunk = input.read(XML_CHUNK_SIZE))
381
+ track_input_bytes(chunk.bytesize)
382
+ table_data_filter.feed(chunk) do |filtered|
383
+ sanitizer.feed(filtered) { |fixed| yield fixed }
384
+ end
385
+ warn_if_no_tables_after_bytes
386
+ end
387
+ end
388
+ table_data_filter.finish do |filtered|
389
+ sanitizer.feed(filtered) { |fixed| yield fixed }
390
+ end
391
+ sanitizer.finish { |fixed| yield fixed }
392
+ warn_if_no_tables_after_bytes
393
+ end
394
+
395
+ def handle_table_data_filter_event(event, table, included)
396
+ case event
397
+ when :table_data_start
398
+ set_current_xml_table(table, included: included)
399
+ when :table_data_end
400
+ set_current_xml_table(nil, included: nil)
401
+ end
402
+ end
403
+
404
+ def set_current_xml_table(table, included: process_table?(table))
405
+ @current_xml_table = table
406
+ @current_xml_table_included = table.nil? ? nil : included
407
+ report_progress(:bytes, force: true) if @current_file_progress && table
408
+ end
409
+
410
+ def track_input_bytes(bytes)
411
+ return unless @current_file_progress
412
+
413
+ @current_file_progress[:bytes_read] += bytes
414
+ @stats[:bytes_read] += bytes
415
+ report_progress(:bytes)
416
+ end
417
+
418
+ def track_excluded_table_data_bytes(bytes)
419
+ @stats[:excluded_table_data_bytes_skipped] += bytes
420
+ report_progress(:bytes)
421
+ end
422
+
423
+ def warn_if_no_tables_after_bytes
424
+ return if @zero_progress_warning_emitted
425
+ unless @zero_progress_warning_bytes &&
426
+ @zero_progress_warning_bytes.positive?
427
+ return
428
+ end
429
+ return if @stats[:bytes_read] < @zero_progress_warning_bytes
430
+ if @stats[:tables_observed].nonzero? || @stats[:rows_observed].nonzero?
431
+ return
432
+ end
433
+ return if @current_xml_table
434
+ return if @stats[:excluded_table_data_bytes_skipped].positive?
435
+
436
+ @zero_progress_warning_emitted = true
437
+ emit_warning(
438
+ "Read #{Format.format_size(@stats[:bytes_read])} from XML source #{@source_path} without discovering any tables or rows. " \
439
+ "Check: input path, command path, file format, current checkout. " \
440
+ "Recommended command: #{recommended_convert_command}"
441
+ )
442
+ end
443
+
444
+ def recommended_convert_command
445
+ source = @source_path ? @source_path.to_s : "SOURCE"
446
+ "structured-data-to-sql xml #{Shellwords.escape(source)} --output converted.sql.gz"
447
+ end
448
+
449
+ def emit_warning(warning)
450
+ diagnostic = Diagnostic.wrap(warning)
451
+ @stats[:warnings] << diagnostic.message
452
+ @stats[:diagnostics] << diagnostic
453
+ log "[WARN] #{diagnostic.message}"
454
+ report_progress(
455
+ :warning,
456
+ message: diagnostic.message,
457
+ diagnostic: diagnostic,
458
+ force: true
459
+ )
460
+ end
461
+
462
+ def write_diagnostics_report(source_label, output_path)
463
+ return if @diagnostics_report == false
464
+
465
+ report_path =
466
+ (
467
+ if @diagnostics_report.is_a?(String) ||
468
+ @diagnostics_report.is_a?(Pathname)
469
+ @diagnostics_report.to_s
470
+ else
471
+ DiagnosticsReport.default_path(output_path)
472
+ end
473
+ )
474
+ return if report_path.nil?
475
+
476
+ finished_at = Time.now
477
+ @stats[:diagnostics_report] = DiagnosticsReport.write(
478
+ report_path,
479
+ format: :xml,
480
+ source: source_label,
481
+ output: output_path.to_s,
482
+ started_at: @conversion_started_at.utc.iso8601,
483
+ finished_at: finished_at.utc.iso8601,
484
+ elapsed: finished_at - @conversion_started_at,
485
+ stats: @stats,
486
+ diagnostics: @stats[:diagnostics],
487
+ ledger: @ledger
488
+ )
489
+ end
490
+
491
+ def record_invalid_xml_char(file:, offset:, codepoint:)
492
+ @stats[:invalid_xml_chars_removed] += 1
493
+ @invalid_xml_report&.record(
494
+ file: file,
495
+ offset: offset,
496
+ codepoint: codepoint
497
+ )
498
+ end
499
+
500
+ def prepare_invalid_xml_report(source, output_path)
501
+ return unless @scrub_invalid_xml_chars
502
+
503
+ summary_path, events_path = invalid_xml_report_paths(output_path)
504
+ @invalid_xml_report =
505
+ MysqlDumpXml::InvalidCharacterReport.new(
506
+ source_path: source,
507
+ output_path: output_path,
508
+ summary_path: summary_path,
509
+ events_path: events_path,
510
+ started_at: @conversion_started_at
511
+ )
512
+ end
513
+
514
+ def finalize_invalid_xml_report(success:)
515
+ return unless @invalid_xml_report
516
+ return if @invalid_xml_report_finalized
517
+
518
+ @invalid_xml_report.finish(success: success)
519
+ @invalid_xml_report_finalized = true
520
+ end
521
+
522
+ def invalid_xml_report_paths(output_path)
523
+ summary_path =
524
+ Pathname(
525
+ @invalid_xml_report_path || "#{output_path}.invalid-xml-chars.json"
526
+ )
527
+ events_path =
528
+ if summary_path.to_s.end_with?(".summary.json")
529
+ Pathname(summary_path.to_s.sub(/\.summary\.json\z/, ".events.jsonl"))
530
+ elsif summary_path.to_s.end_with?(".json")
531
+ Pathname(summary_path.to_s.sub(/\.json\z/, ".events.jsonl"))
532
+ else
533
+ Pathname("#{summary_path}.events.jsonl")
534
+ end
535
+ [summary_path, events_path]
536
+ end
537
+
538
+ def report_progress(event, extra = {})
539
+ return unless @progress_callback || @on_progress
540
+
541
+ force = extra.delete(:force)
542
+ now = Time.now
543
+ if !force && @last_progress_at &&
544
+ now - @last_progress_at < @progress_interval
545
+ return
546
+ end
547
+
548
+ @last_progress_at = now
549
+ payload = progress_snapshot(event, extra)
550
+ @progress_callback&.call(payload)
551
+ @on_progress&.call(
552
+ ProgressEvent.new(event, payload.reject { |key, _| key == :event })
553
+ )
554
+ end
555
+
556
+ def progress_snapshot(event, extra)
557
+ {
558
+ event: event,
559
+ elapsed: Time.now - @conversion_started_at,
560
+ files_processed: @stats[:files_processed],
561
+ tables_processed: @stats[:tables_processed],
562
+ tables_observed: @stats[:tables_observed],
563
+ tables_skipped: @stats[:tables_skipped],
564
+ rows_processed: @stats[:rows_processed],
565
+ bytes_read: @stats[:bytes_read],
566
+ excluded_table_data_bytes_skipped:
567
+ @stats[:excluded_table_data_bytes_skipped],
568
+ total_input_bytes: @total_input_bytes,
569
+ total_work_bytes: @total_input_bytes,
570
+ work_bytes_read: @stats[:bytes_read],
571
+ warnings_count: @stats[:warnings].length,
572
+ current_xml_table: @current_xml_table,
573
+ current_xml_table_included: @current_xml_table_included,
574
+ current_file: @current_file_progress&.dup
575
+ }.merge(extra)
576
+ end
577
+
578
+ def temporary_output_path(path)
579
+ Pathname("#{path}.tmp")
580
+ end
581
+
582
+ def open_input(path)
583
+ if path.to_s.end_with?(".gz")
584
+ Zlib::GzipReader.open(path.to_s) { |gz| yield gz }
585
+ else
586
+ File.open(path, "rb") { |file| yield file }
587
+ end
588
+ end
589
+
590
+ def reset_stats
591
+ @stats = {
592
+ tables_processed: 0,
593
+ rows_processed: 0,
594
+ tables_observed: 0,
595
+ rows_observed: 0,
596
+ bytes_read: 0,
597
+ bytes_written: 0,
598
+ files_processed: 0,
599
+ files_skipped: 0,
600
+ tables_skipped: 0,
601
+ excluded_table_data_bytes_skipped: 0,
602
+ invalid_xml_chars_removed: 0,
603
+ warnings: [],
604
+ diagnostics: [],
605
+ diagnostics_report: nil
606
+ }
607
+ @ledger = []
608
+ end
609
+
610
+ def open_output(path, gzip: nil)
611
+ gzip = path.to_s.end_with?(".gz") if gzip.nil?
612
+ if gzip
613
+ Zlib::GzipWriter.open(path.to_s) { |gz| yield gz }
614
+ else
615
+ File.open(path, "w:utf-8") { |file| yield file }
616
+ end
617
+ end
618
+
619
+ def process_table?(table)
620
+ return false if @include_tables && !@include_tables.include?(table)
621
+ return false if @exclude_tables.include?(table)
622
+
623
+ true
624
+ end
625
+
626
+ def process_file?(file)
627
+ file_name = file.basename.to_s
628
+ base = file_name.sub(/\.gz\z/, "").sub(/\.xml\z/, "")
629
+ if @include_files && !@include_files.include?(file_name) &&
630
+ !@include_files.include?(base)
631
+ return false
632
+ end
633
+ if @exclude_files.include?(file_name) || @exclude_files.include?(base)
634
+ return false
635
+ end
636
+
637
+ true
638
+ end
639
+
640
+ def log(message)
641
+ @diagnostic_io.puts(message) if @verbose && @diagnostic_io
642
+ end
643
+ end
644
+
645
+ TableStructure = MysqlDumpXml::TableStructure
646
+ XMLTableDiscovery = MysqlDumpXml::TableDiscovery
647
+
648
+ # Compatibility aliases for the original format-specific API.
649
+ MysqlDumpXmlConverter = XmlConverter
650
+ XmlDumpConverter = XmlConverter
651
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Compatibility require for the original extraction filename.
4
+ require_relative "xml_converter"
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "structured_data_to_sql/version"
4
+ require_relative "structured_data_to_sql/errors"
5
+ require_relative "structured_data_to_sql/conversion_result"
6
+ require_relative "structured_data_to_sql/io_support"
7
+ require_relative "structured_data_to_sql/options"
8
+ require_relative "structured_data_to_sql/format"
9
+ require_relative "structured_data_to_sql/progress_reporter"
10
+ require_relative "structured_data_to_sql/sql_text"
11
+ require_relative "structured_data_to_sql/json_converter"
12
+ require_relative "structured_data_to_sql/xml_converter"
13
+
14
+ module StructuredDataToSql
15
+ FORMATS = %i[json xml].freeze
16
+
17
+ def self.convert(format:, source:, output:, **options)
18
+ conversion_keys = %i[
19
+ on_progress
20
+ progress_callback
21
+ atomic
22
+ progress_interval
23
+ file_pattern
24
+ input_gzip
25
+ output_gzip
26
+ ]
27
+ conversion_options =
28
+ options.select { |key, _| conversion_keys.include?(key) }
29
+ initializer_options =
30
+ options.reject { |key, _| conversion_keys.include?(key) }
31
+ initializer_options[:input_gzip] = options[:input_gzip] if options.key?(
32
+ :input_gzip
33
+ )
34
+ initializer_options[:output_gzip] = options[:output_gzip] if options.key?(
35
+ :output_gzip
36
+ )
37
+ converter =
38
+ case format.to_sym
39
+ when :json, :ndjson
40
+ initializer_options[:ndjson] = true if format.to_sym == :ndjson
41
+ JsonConverter
42
+ when :xml, :mysql_dump_xml, :mysql_xml
43
+ XmlConverter
44
+ else
45
+ raise ConfigurationError,
46
+ "Unsupported format #{format.inspect}; expected :json or :xml"
47
+ end
48
+ converter.new(**initializer_options).convert(
49
+ source:,
50
+ output:,
51
+ **conversion_options
52
+ )
53
+ end
54
+ end