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 "zlib"
4
+ require_relative "../errors"
5
+ require_relative "../io_support"
6
+ require_relative "../conversion_result"
7
+
8
+ module StructuredDataToSql
9
+ module MysqlDumpXml
10
+ class TableDiscovery
11
+ FileResult =
12
+ Struct.new(
13
+ :path,
14
+ :tables,
15
+ :table_metadata,
16
+ :bytes_scanned,
17
+ :truncated,
18
+ keyword_init: true
19
+ ) do
20
+ def count
21
+ tables.length
22
+ end
23
+ end
24
+
25
+ Result =
26
+ Struct.new(
27
+ :tables,
28
+ :files,
29
+ :files_found,
30
+ :files_skipped,
31
+ :skipped,
32
+ :table_metadata,
33
+ :bytes_scanned,
34
+ :truncated,
35
+ keyword_init: true
36
+ ) do
37
+ def sorted_tables
38
+ sized, unsized =
39
+ tables.partition do |table|
40
+ TableDiscovery.size_metadata?(table_metadata[table])
41
+ end
42
+ sized.sort_by do |table|
43
+ metadata = table_metadata[table] || {}
44
+ [
45
+ -TableDiscovery.total_size(metadata),
46
+ -metadata[:rows].to_i,
47
+ table
48
+ ]
49
+ end + unsized.sort
50
+ end
51
+ end
52
+
53
+ CDATA_START = "<![CDATA[".b
54
+ CDATA_END = "]]>".b
55
+ COMMENT_START = "<!--".b
56
+ COMMENT_END = "-->".b
57
+ PROCESSING_START = "<?".b
58
+ PROCESSING_END = "?>".b
59
+ TABLE_DATA_END_START = "</table_data".b
60
+ DOCUMENT_MARKER =
61
+ /<\s*(?:table_structure|table_data|options)\b|<!\[CDATA\[|<!--|<\?/n
62
+ TABLE_DATA_MARKER = %r{<!\[CDATA\[|<!--|<\?|</table_data\b}n
63
+ TAG_NAME = /\A<\s*([A-Za-z_:][\w:.-]*)/n
64
+ ATTR = /\b([A-Za-z_:][\w:.-]*)\s*=\s*(['"])(.*?)\2/mn
65
+ SCAN_TAIL_BYTES = 64
66
+
67
+ def initialize(
68
+ include_files: nil,
69
+ exclude_files: nil,
70
+ chunk_size: 1024 * 1024,
71
+ max_bytes: nil
72
+ )
73
+ @include_files = include_files&.to_set
74
+ @exclude_files = Array(exclude_files).to_set
75
+ @chunk_size = chunk_size
76
+ @max_bytes = max_bytes
77
+ end
78
+
79
+ def discover(
80
+ source,
81
+ file_pattern: "*.xml",
82
+ progress_callback: nil,
83
+ on_progress: nil,
84
+ progress_interval: 5,
85
+ input_gzip: false
86
+ )
87
+ files, temporary_inputs =
88
+ IOSupport.discover(
89
+ source,
90
+ patterns: [file_pattern, "#{file_pattern}.gz"].uniq,
91
+ stream_extension: ".xml",
92
+ input_gzip:
93
+ )
94
+ raise UsageError, "No XML files found in #{source}" if files.empty?
95
+
96
+ selected, skipped = files.partition { |file| process_file?(file) }
97
+ if selected.empty?
98
+ raise UsageError,
99
+ "No XML files to scan after filtering. All #{files.length} files were excluded."
100
+ end
101
+
102
+ progress =
103
+ DiscoveryProgress.new(
104
+ callback: progress_callback,
105
+ on_progress: on_progress,
106
+ interval: progress_interval,
107
+ total_input_bytes:
108
+ selected.sum do |file|
109
+ begin
110
+ file.size
111
+ rescue StandardError
112
+ 0
113
+ end
114
+ end
115
+ )
116
+ progress.report(:start, force: true, file_count: selected.length)
117
+
118
+ file_results =
119
+ selected.each_with_index.map do |file, index|
120
+ progress.start_file(file, index: index + 1, count: selected.length)
121
+ tables, metadata, bytes_scanned, truncated =
122
+ discover_file(file, progress: progress)
123
+ progress.finish_file(force: true)
124
+ FileResult.new(
125
+ path: file,
126
+ tables: tables.sort,
127
+ table_metadata: metadata,
128
+ bytes_scanned: bytes_scanned,
129
+ truncated: truncated
130
+ )
131
+ end
132
+ result =
133
+ Result.new(
134
+ tables: file_results.flat_map(&:tables).uniq.sort,
135
+ files: file_results,
136
+ files_found: files.length,
137
+ files_skipped: skipped.length,
138
+ skipped: skipped,
139
+ table_metadata: merge_table_metadata(file_results),
140
+ bytes_scanned: file_results.sum(&:bytes_scanned),
141
+ truncated: file_results.any?(&:truncated)
142
+ )
143
+ progress.report(:complete, force: true, tables: result.tables.length)
144
+ result
145
+ rescue StructuredDataToSql::InputError => e
146
+ raise MysqlDumpXml::InputError, e.message
147
+ ensure
148
+ temporary_inputs&.each(&:unlink)
149
+ end
150
+
151
+ def self.total_size(metadata)
152
+ return 0 unless metadata
153
+
154
+ metadata.values_at(:data_length, :index_length).compact.sum
155
+ end
156
+
157
+ def self.size_metadata?(metadata)
158
+ metadata &&
159
+ (metadata.key?(:data_length) || metadata.key?(:index_length))
160
+ end
161
+
162
+ private
163
+
164
+ class DiscoveryProgress
165
+ def initialize(callback:, on_progress:, interval:, total_input_bytes:)
166
+ @callback = callback
167
+ @on_progress = on_progress
168
+ @interval = interval
169
+ @total_input_bytes = total_input_bytes
170
+ @started_at = Time.now
171
+ @last_progress_at = nil
172
+ @bytes_scanned = 0
173
+ @current_file = nil
174
+ end
175
+
176
+ def start_file(file, index:, count:)
177
+ @current_file = {
178
+ path: file.to_s,
179
+ name: file.basename.to_s,
180
+ index: index,
181
+ count: count,
182
+ size:
183
+ (
184
+ begin
185
+ file.size
186
+ rescue StandardError
187
+ 0
188
+ end
189
+ ),
190
+ bytes_scanned: 0
191
+ }
192
+ report(:file_start, force: true)
193
+ end
194
+
195
+ def advance(bytes, tables:)
196
+ @bytes_scanned += bytes
197
+ @current_file[:bytes_scanned] += bytes if @current_file
198
+ report(:bytes, tables: tables)
199
+ end
200
+
201
+ def finish_file(force:)
202
+ report(:file_complete, force: force)
203
+ @current_file = nil
204
+ end
205
+
206
+ def report(event, force: false, **extra)
207
+ return unless @callback || @on_progress
208
+
209
+ now = Time.now
210
+ if !force && @last_progress_at && now - @last_progress_at < @interval
211
+ return
212
+ end
213
+
214
+ @last_progress_at = now
215
+ payload = {
216
+ event: event,
217
+ elapsed: now - @started_at,
218
+ bytes_scanned: @bytes_scanned,
219
+ total_input_bytes: @total_input_bytes,
220
+ current_file: @current_file&.dup
221
+ }.merge(extra)
222
+ @callback&.call(payload)
223
+ @on_progress&.call(
224
+ ProgressEvent.new(event, payload.reject { |key, _| key == :event })
225
+ )
226
+ end
227
+ end
228
+
229
+ def discover_file(path, progress:)
230
+ tables = Set.new
231
+ metadata = {}
232
+ bytes_scanned = 0
233
+ truncated = false
234
+ scanner =
235
+ TableTagScanner.new(chunk_size: @chunk_size) do |event, table, info|
236
+ case event
237
+ when :table
238
+ tables << table
239
+ when :metadata
240
+ tables << table
241
+ metadata[table] = merge_metadata(metadata[table], info)
242
+ when :xml_size
243
+ tables << table
244
+ metadata[table] = merge_metadata(metadata[table], info)
245
+ end
246
+ end
247
+ open_input(path) do |input|
248
+ while (chunk = input.read(@chunk_size))
249
+ if @max_bytes && bytes_scanned + chunk.bytesize > @max_bytes
250
+ remaining = @max_bytes - bytes_scanned
251
+ if remaining.positive?
252
+ scanner.feed(chunk.byteslice(0, remaining))
253
+ bytes_scanned += remaining
254
+ progress.advance(remaining, tables: tables.length)
255
+ end
256
+ truncated = true
257
+ break
258
+ end
259
+
260
+ scanner.feed(chunk)
261
+ bytes_scanned += chunk.bytesize
262
+ progress.advance(chunk.bytesize, tables: tables.length)
263
+ end
264
+ end
265
+ scanner.finish
266
+ [tables.to_a, metadata, bytes_scanned, truncated]
267
+ end
268
+
269
+ def merge_table_metadata(file_results)
270
+ file_results.each_with_object({}) do |file, merged|
271
+ file.table_metadata.each do |table, info|
272
+ merged[table] = merge_metadata(merged[table], info)
273
+ end
274
+ end
275
+ end
276
+
277
+ def merge_metadata(existing, incoming)
278
+ return incoming if existing.nil?
279
+
280
+ existing.merge(incoming) do |key, old_value, new_value|
281
+ if key == :xml_data_bytes
282
+ old_value.to_i + new_value.to_i
283
+ elsif old_value.is_a?(Integer) && new_value.is_a?(Integer)
284
+ [old_value, new_value].max
285
+ else
286
+ old_value || new_value
287
+ end
288
+ end
289
+ end
290
+
291
+ class TableTagScanner
292
+ def initialize(chunk_size:, &event_handler)
293
+ @event_handler = event_handler
294
+ @chunk_size = chunk_size
295
+ @buffer = +"".b
296
+ @cursor = 0
297
+ @skip_until = nil
298
+ @skip_table_data_body = false
299
+ @table_data_skip_until = nil
300
+ @table_data_table = nil
301
+ @table_data_bytes = 0
302
+ @current_structure_table = nil
303
+ end
304
+
305
+ def feed(chunk)
306
+ @buffer << chunk.to_s.b
307
+ scan(final: false)
308
+ end
309
+
310
+ def finish
311
+ scan(final: true)
312
+ end
313
+
314
+ private
315
+
316
+ def scan(final:)
317
+ loop do
318
+ if @skip_table_data_body
319
+ break unless drain_table_data_body(final: final)
320
+
321
+ next
322
+ end
323
+
324
+ if @skip_until
325
+ break unless drain_skip(final: final)
326
+
327
+ next
328
+ end
329
+
330
+ marker = next_marker
331
+ unless marker
332
+ advance_document_cursor(final: final)
333
+ break
334
+ end
335
+
336
+ index, type = marker
337
+ if type == :tag
338
+ tag_end = start_tag_end(index)
339
+ unless tag_end
340
+ @cursor = index
341
+ discard_oversized_partial_tag
342
+ break
343
+ end
344
+
345
+ process_tag(@buffer.byteslice(index, tag_end - index + 1))
346
+ @cursor = tag_end + 1
347
+ else
348
+ start, ending = skip_markers(type)
349
+ @cursor = index + start.bytesize
350
+ @skip_until = ending
351
+ drain_skip(final: final)
352
+ end
353
+ end
354
+ ensure
355
+ compact_buffer
356
+ end
357
+
358
+ def next_marker
359
+ match = DOCUMENT_MARKER.match(@buffer, @cursor)
360
+ return unless match
361
+
362
+ index = match.begin(0)
363
+ [index, marker_type(index)]
364
+ end
365
+
366
+ def start_tag_end(index)
367
+ quote = nil
368
+ i = index
369
+ while i < @buffer.bytesize
370
+ char = @buffer.getbyte(i)
371
+ if quote
372
+ quote = nil if char == quote
373
+ elsif char == 34 || char == 39
374
+ quote = char
375
+ elsif char == 62
376
+ return i
377
+ end
378
+ i += 1
379
+ end
380
+ nil
381
+ end
382
+
383
+ def process_tag(tag)
384
+ tag_name = tag[TAG_NAME, 1]
385
+ attrs = parse_attributes(tag)
386
+
387
+ case tag_name
388
+ when "table_structure"
389
+ table = decoded_attribute(attrs["name"])
390
+ return if table.empty?
391
+
392
+ @current_structure_table = table
393
+ @event_handler.call(:table, table, nil)
394
+ when "table_data"
395
+ table = decoded_attribute(attrs["name"])
396
+ @event_handler.call(:table, table, nil) unless table.empty?
397
+ if tag.end_with?("/>".b)
398
+ unless table.empty?
399
+ @event_handler.call(
400
+ :xml_size,
401
+ table,
402
+ { xml_data_bytes: tag.bytesize }
403
+ )
404
+ end
405
+ else
406
+ @skip_table_data_body = true
407
+ @table_data_table = table
408
+ @table_data_bytes = tag.bytesize
409
+ end
410
+ when "options"
411
+ table =
412
+ decoded_attribute(attrs["Name"] || attrs["name"]) ||
413
+ @current_structure_table
414
+ table = @current_structure_table if table.to_s.empty?
415
+ metadata = options_metadata(attrs)
416
+ if table && !table.empty? && metadata.any?
417
+ @event_handler.call(:metadata, table, metadata)
418
+ end
419
+ end
420
+ end
421
+
422
+ def drain_table_data_body(final:)
423
+ return drain_table_data_special(final:) if @table_data_skip_until
424
+
425
+ marker = next_table_data_marker
426
+ unless marker
427
+ keep =
428
+ (
429
+ if final
430
+ 0
431
+ else
432
+ [SCAN_TAIL_BYTES, TABLE_DATA_END_START.bytesize - 1].max
433
+ end
434
+ )
435
+ consume_table_data_to([@buffer.bytesize - keep, @cursor].max)
436
+ return false
437
+ end
438
+
439
+ index, type = marker
440
+ if type == :table_data_end
441
+ tag_end = start_tag_end(index)
442
+ unless tag_end
443
+ consume_table_data_to(index)
444
+ return false
445
+ end
446
+
447
+ consume_table_data_to(tag_end + 1)
448
+ finish_table_data
449
+ true
450
+ else
451
+ start, ending = skip_markers(type)
452
+ consume_table_data_to(index + start.bytesize)
453
+ @table_data_skip_until = ending
454
+ true
455
+ end
456
+ end
457
+
458
+ def drain_table_data_special(final:)
459
+ index = @buffer.index(@table_data_skip_until, @cursor)
460
+ if index
461
+ consume_table_data_to(index + @table_data_skip_until.bytesize)
462
+ @table_data_skip_until = nil
463
+ true
464
+ else
465
+ keep =
466
+ (
467
+ if final
468
+ 0
469
+ else
470
+ [SCAN_TAIL_BYTES, @table_data_skip_until.bytesize - 1].max
471
+ end
472
+ )
473
+ consume_table_data_to([@buffer.bytesize - keep, @cursor].max)
474
+ false
475
+ end
476
+ end
477
+
478
+ def next_table_data_marker
479
+ match = TABLE_DATA_MARKER.match(@buffer, @cursor)
480
+ return unless match
481
+
482
+ index = match.begin(0)
483
+ [index, marker_type(index)]
484
+ end
485
+
486
+ def marker_type(index)
487
+ case @buffer.getbyte(index + 1)
488
+ when 33
489
+ @buffer.getbyte(index + 2) == 45 ? :comment : :cdata
490
+ when 63
491
+ :processing
492
+ when 47
493
+ :table_data_end
494
+ else
495
+ :tag
496
+ end
497
+ end
498
+
499
+ def consume_table_data_to(position)
500
+ bytes = position - @cursor
501
+ return unless bytes.positive?
502
+
503
+ @table_data_bytes += bytes
504
+ @cursor = position
505
+ end
506
+
507
+ def finish_table_data
508
+ unless @table_data_table.to_s.empty?
509
+ @event_handler.call(
510
+ :xml_size,
511
+ @table_data_table,
512
+ { xml_data_bytes: @table_data_bytes }
513
+ )
514
+ end
515
+ @skip_table_data_body = false
516
+ @table_data_skip_until = nil
517
+ @table_data_table = nil
518
+ @table_data_bytes = 0
519
+ end
520
+
521
+ def parse_attributes(tag)
522
+ tag
523
+ .scan(ATTR)
524
+ .each_with_object({}) do |(name, _quote, value), attrs|
525
+ attrs[name] = value
526
+ end
527
+ end
528
+
529
+ def decoded_attribute(value)
530
+ return "" if value.nil?
531
+
532
+ value =
533
+ value
534
+ .dup
535
+ .force_encoding("UTF-8")
536
+ .encode("UTF-8", invalid: :replace, undef: :replace)
537
+ unescape_xml_attribute(value)
538
+ end
539
+
540
+ def options_metadata(attrs)
541
+ {
542
+ rows: integer_attribute(attrs["Rows"] || attrs["rows"]),
543
+ data_length:
544
+ integer_attribute(attrs["Data_length"] || attrs["data_length"]),
545
+ index_length:
546
+ integer_attribute(attrs["Index_length"] || attrs["index_length"])
547
+ }.compact
548
+ end
549
+
550
+ def integer_attribute(value)
551
+ return nil if value.nil? || value.empty?
552
+
553
+ Integer(value, 10)
554
+ rescue ArgumentError
555
+ nil
556
+ end
557
+
558
+ def unescape_xml_attribute(value)
559
+ value.gsub(
560
+ /&(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);/
561
+ ) do |entity|
562
+ case entity
563
+ when "&amp;"
564
+ "&"
565
+ when "&lt;"
566
+ "<"
567
+ when "&gt;"
568
+ ">"
569
+ when "&quot;"
570
+ '"'
571
+ when "&apos;"
572
+ "'"
573
+ when /\A&#(\d+);\z/
574
+ Regexp.last_match(1).to_i.chr(Encoding::UTF_8)
575
+ when /\A&#x([0-9a-fA-F]+);\z/
576
+ Regexp.last_match(1).to_i(16).chr(Encoding::UTF_8)
577
+ else
578
+ entity
579
+ end
580
+ rescue RangeError
581
+ entity
582
+ end
583
+ end
584
+
585
+ def skip_markers(type)
586
+ case type
587
+ when :cdata
588
+ [CDATA_START, CDATA_END]
589
+ when :comment
590
+ [COMMENT_START, COMMENT_END]
591
+ when :processing
592
+ [PROCESSING_START, PROCESSING_END]
593
+ end
594
+ end
595
+
596
+ def drain_skip(final:)
597
+ index = @buffer.index(@skip_until, @cursor)
598
+ if index
599
+ @cursor = index + @skip_until.bytesize
600
+ @skip_until = nil
601
+ true
602
+ else
603
+ keep = final ? 0 : [SCAN_TAIL_BYTES, @skip_until.bytesize - 1].max
604
+ @cursor = [@buffer.bytesize - keep, @cursor].max
605
+ false
606
+ end
607
+ end
608
+
609
+ def advance_document_cursor(final:)
610
+ keep = final ? 0 : SCAN_TAIL_BYTES
611
+ @cursor = [@buffer.bytesize - keep, @cursor].max
612
+ end
613
+
614
+ def discard_oversized_partial_tag
615
+ return if @buffer.bytesize - @cursor <= @chunk_size + SCAN_TAIL_BYTES
616
+
617
+ @cursor += 1
618
+ end
619
+
620
+ def compact_buffer
621
+ return if @cursor.zero?
622
+
623
+ @buffer.slice!(0, @cursor)
624
+ @cursor = 0
625
+ end
626
+ end
627
+
628
+ def open_input(path)
629
+ if path.to_s.end_with?(".gz")
630
+ Zlib::GzipReader.open(path.to_s) { |gz| yield gz }
631
+ else
632
+ File.open(path, "rb") { |file| yield file }
633
+ end
634
+ end
635
+
636
+ def process_file?(file)
637
+ file_name = file.basename.to_s
638
+ base = file_name.sub(/\.gz\z/, "").sub(/\.xml\z/, "")
639
+ if @include_files && !@include_files.include?(file_name) &&
640
+ !@include_files.include?(base)
641
+ return false
642
+ end
643
+ if @exclude_files.include?(file_name) || @exclude_files.include?(base)
644
+ return false
645
+ end
646
+
647
+ true
648
+ end
649
+ end
650
+ end
651
+ end