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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "pathname"
6
+ require "time"
7
+
8
+ module StructuredDataToSql
9
+ module MysqlDumpXml
10
+ class InvalidCharacterReport
11
+ attr_reader :summary_path, :events_path, :total
12
+
13
+ def initialize(
14
+ source_path:,
15
+ output_path:,
16
+ summary_path:,
17
+ events_path:,
18
+ started_at:
19
+ )
20
+ @source_path = source_path.to_s
21
+ @output_path = output_path.to_s
22
+ @summary_path = Pathname(summary_path)
23
+ @events_path = Pathname(events_path)
24
+ @started_at = started_at
25
+ @total = 0
26
+ @per_file = Hash.new(0)
27
+ @per_codepoint = Hash.new(0)
28
+ @events = nil
29
+ end
30
+
31
+ def record(file:, offset:, codepoint:)
32
+ open_events
33
+ hex = format("U+%04X", codepoint)
34
+ event = {
35
+ source_file: file.to_s,
36
+ input_byte_offset: offset,
37
+ codepoint: hex,
38
+ decimal: codepoint,
39
+ hex: hex,
40
+ action: "removed"
41
+ }
42
+ @events.write("#{JSON.generate(event)}\n")
43
+ @total += 1
44
+ @per_file[file.to_s] += 1
45
+ @per_codepoint[hex] += 1
46
+ end
47
+
48
+ def finish(success:)
49
+ return if @total.zero?
50
+
51
+ @events&.close
52
+ @events = nil
53
+ FileUtils.mkdir_p(@summary_path.dirname)
54
+ summary = {
55
+ source_path: @source_path,
56
+ output_path: @output_path,
57
+ started_at: @started_at.utc.iso8601,
58
+ completed_at: Time.now.utc.iso8601,
59
+ success: success,
60
+ total_scrubbed: @total,
61
+ per_file_totals: @per_file,
62
+ per_codepoint_totals: @per_codepoint,
63
+ events_path: @events_path.to_s
64
+ }
65
+ File.write(
66
+ @summary_path,
67
+ JSON.pretty_generate(summary),
68
+ mode: "w:utf-8"
69
+ )
70
+ end
71
+
72
+ private
73
+
74
+ def open_events
75
+ return if @events
76
+
77
+ FileUtils.mkdir_p(@events_path.dirname)
78
+ @events = File.open(@events_path, "w:utf-8")
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module StructuredDataToSql
6
+ module MysqlDumpXml
7
+ class Sanitizer
8
+ UNESCAPED_AMP = /&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/
9
+ XML_SANITIZER_TAIL = 128
10
+ CDATA_START = "<![CDATA["
11
+ CDATA_END = "]]>"
12
+
13
+ def initialize(path:, scrub_invalid_xml_chars:, invalid_xml_handler:)
14
+ @buffer = +""
15
+ @in_cdata = false
16
+ @path = path
17
+ @scrub_invalid_xml_chars = scrub_invalid_xml_chars
18
+ @invalid_xml_handler = invalid_xml_handler
19
+ @input_byte_offset = 0
20
+ end
21
+
22
+ def feed(chunk, &block)
23
+ @buffer << clean_chunk(chunk)
24
+ drain(final: false, &block)
25
+ end
26
+
27
+ def finish(&block)
28
+ drain(final: true, &block)
29
+ end
30
+
31
+ def skip_input_bytes(bytes)
32
+ @input_byte_offset += bytes
33
+ end
34
+
35
+ private
36
+
37
+ def drain(final:, &block)
38
+ loop do
39
+ progressed =
40
+ @in_cdata ? drain_cdata(final, &block) : drain_text(final, &block)
41
+ break unless progressed
42
+ end
43
+ end
44
+
45
+ def drain_cdata(final)
46
+ index = @buffer.index(CDATA_END)
47
+ if index
48
+ yield @buffer.slice!(0, index + CDATA_END.length)
49
+ @in_cdata = false
50
+ true
51
+ else
52
+ length =
53
+ (
54
+ if final
55
+ @buffer.length
56
+ else
57
+ [@buffer.length - (CDATA_END.length - 1), 0].max
58
+ end
59
+ )
60
+ yield @buffer.slice!(0, length) if length.positive?
61
+ false
62
+ end
63
+ end
64
+
65
+ def drain_text(final)
66
+ index = @buffer.index(CDATA_START)
67
+ if index
68
+ yield fix_text(@buffer.slice!(0, index))
69
+ yield @buffer.slice!(0, CDATA_START.length)
70
+ @in_cdata = true
71
+ true
72
+ elsif final
73
+ unless @buffer.empty?
74
+ yield fix_text(@buffer.slice!(0, @buffer.length))
75
+ end
76
+ false
77
+ else
78
+ length =
79
+ safe_text_drain_length([@buffer.length - XML_SANITIZER_TAIL, 0].max)
80
+ yield fix_text(@buffer.slice!(0, length)) if length.positive?
81
+ false
82
+ end
83
+ end
84
+
85
+ def safe_text_drain_length(length)
86
+ return length unless length.positive?
87
+
88
+ amp = @buffer.rindex("&", length - 1)
89
+ return length unless amp
90
+
91
+ semicolon = @buffer.index(";", amp)
92
+ semicolon && semicolon < length ? length : amp
93
+ end
94
+
95
+ def fix_text(text)
96
+ text.gsub(UNESCAPED_AMP, "&amp;")
97
+ end
98
+
99
+ def clean_chunk(chunk)
100
+ raw = chunk.to_s.b
101
+ cleaned = scrub_invalid_controls(raw)
102
+ cleaned.force_encoding("UTF-8").encode(
103
+ "UTF-8",
104
+ invalid: :replace,
105
+ undef: :replace
106
+ )
107
+ end
108
+
109
+ def scrub_invalid_controls(raw)
110
+ result = nil
111
+ keep_from = 0
112
+ raw.bytes.each_with_index do |byte, index|
113
+ next unless invalid_xml_control_byte?(byte)
114
+
115
+ offset = @input_byte_offset + index
116
+ unless @scrub_invalid_xml_chars
117
+ raise UsageError,
118
+ "Invalid XML control character #{format_codepoint(byte)} in #{@path} at input byte offset #{offset}. " \
119
+ "XML 1.0 forbids this character; remove --no-scrub-invalid-xml-chars to scrub it and write an audit report."
120
+ end
121
+
122
+ result ||= +"".b
123
+ if index > keep_from
124
+ result << raw.byteslice(keep_from, index - keep_from)
125
+ end
126
+ @invalid_xml_handler&.call(
127
+ file: @path,
128
+ offset: offset,
129
+ codepoint: byte
130
+ )
131
+ keep_from = index + 1
132
+ end
133
+ @input_byte_offset += raw.bytesize
134
+ return raw unless result
135
+
136
+ if keep_from < raw.bytesize
137
+ result << raw.byteslice(keep_from, raw.bytesize - keep_from)
138
+ end
139
+ result
140
+ end
141
+
142
+ def invalid_xml_control_byte?(byte)
143
+ byte <= 0x08 || byte == 0x0B || byte == 0x0C ||
144
+ (byte >= 0x0E && byte <= 0x1F)
145
+ end
146
+
147
+ def format_codepoint(codepoint)
148
+ format("U+%04X", codepoint)
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require_relative "table_structure"
5
+
6
+ module StructuredDataToSql
7
+ module MysqlDumpXml
8
+ class XMLStreamHandler
9
+ def initialize(&event_handler)
10
+ @event_handler = event_handler
11
+ @current_structure = nil
12
+ @current_table_data = nil
13
+ @current_row = nil
14
+ @current_field_name = nil
15
+ @current_field_text = nil
16
+ end
17
+
18
+ def tag_start(name, attrs)
19
+ case name
20
+ when "table_structure"
21
+ @current_structure = TableStructure.new(attrs["name"].to_s)
22
+ when "table_data"
23
+ @current_table_data = attrs["name"].to_s
24
+ when "row"
25
+ @current_row = {}
26
+ when "field"
27
+ if @current_structure
28
+ @current_structure.add_column(attrs)
29
+ elsif @current_table_data
30
+ @current_field_name = attrs["name"].to_s
31
+ @current_field_text = +""
32
+ end
33
+ end
34
+ end
35
+
36
+ def text(value)
37
+ @current_field_text << value if @current_field_name
38
+ end
39
+
40
+ def cdata(value)
41
+ @current_field_text << value if @current_field_name
42
+ end
43
+
44
+ def tag_end(name)
45
+ case name
46
+ when "table_structure"
47
+ if @current_structure
48
+ @event_handler.call(
49
+ :structure,
50
+ @current_structure.name,
51
+ @current_structure
52
+ )
53
+ @current_structure = nil
54
+ end
55
+ when "field"
56
+ @current_row[
57
+ @current_field_name
58
+ ] = @current_field_text if @current_field_name && @current_row
59
+ @current_field_name = nil
60
+ @current_field_text = nil
61
+ when "row"
62
+ if @current_table_data && @current_row
63
+ @event_handler.call(:row, @current_table_data, @current_row)
64
+ end
65
+ @current_row = nil
66
+ when "table_data"
67
+ @current_table_data = nil
68
+ end
69
+ end
70
+ end
71
+
72
+ class NokogiriStreamListener < Nokogiri::XML::SAX::Document
73
+ def initialize(&event_handler)
74
+ @handler = XMLStreamHandler.new(&event_handler)
75
+ end
76
+
77
+ def start_element(name, attrs = [])
78
+ @handler.tag_start(name, attrs.to_h)
79
+ end
80
+
81
+ def characters(value)
82
+ @handler.text(value)
83
+ end
84
+
85
+ def cdata_block(value)
86
+ @handler.cdata(value)
87
+ end
88
+
89
+ def end_element(name)
90
+ @handler.tag_end(name)
91
+ end
92
+ end
93
+
94
+ class SaxParser
95
+ def initialize(&event_handler)
96
+ @parser =
97
+ Nokogiri::XML::SAX::PushParser.new(
98
+ NokogiriStreamListener.new(&event_handler)
99
+ )
100
+ end
101
+
102
+ def <<(chunk)
103
+ @parser << chunk
104
+ end
105
+
106
+ def finish
107
+ @parser.finish
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sql_text"
4
+
5
+ module StructuredDataToSql
6
+ module MysqlDumpXml
7
+ class SqlEmitter
8
+ def write_header(out, source_label:)
9
+ out.write("-- Generated by xml-to-sql converter\n")
10
+ out.write("-- Source: #{source_label}\n")
11
+ out.write(
12
+ "-- Generated at: #{Time.now.strftime("%Y-%m-%d %H:%M:%S")}\n\n"
13
+ )
14
+ out.write(
15
+ "-- XML dumps are generated in autocommit mode to avoid huge transaction commits.\n"
16
+ )
17
+ out.write(
18
+ "SET NAMES utf8mb4;\nSET sql_mode = '';\nSET FOREIGN_KEY_CHECKS = 0;\nSET UNIQUE_CHECKS = 0;\n\n"
19
+ )
20
+ end
21
+
22
+ def write_footer(out)
23
+ out.write("\nSET FOREIGN_KEY_CHECKS = 1;\nSET UNIQUE_CHECKS = 1;\n")
24
+ end
25
+
26
+ def column_formats_for(structure, sample_row)
27
+ if structure
28
+ structure.columns.map do |col|
29
+ {
30
+ name: col[:name],
31
+ escaped_name: SqlText.escape_identifier(col[:name]),
32
+ type: col[:type],
33
+ allows_null: col[:null],
34
+ unique: col[:key] == "UNI",
35
+ numeric: numeric_type?(col[:type])
36
+ }
37
+ end
38
+ else
39
+ sample_row.keys.map do |name|
40
+ {
41
+ name: name,
42
+ escaped_name: SqlText.escape_identifier(name),
43
+ type: "text",
44
+ allows_null: true,
45
+ unique: false,
46
+ numeric: false
47
+ }
48
+ end
49
+ end
50
+ end
51
+
52
+ def write_rows_batch(out, table, rows, column_formats:)
53
+ return if rows.empty?
54
+
55
+ buffer = +"INSERT INTO #{SqlText.escape_identifier(table)} ("
56
+ column_formats.each_with_index do |format, index|
57
+ buffer << ", " if index.positive?
58
+ buffer << format[:escaped_name]
59
+ end
60
+ buffer << ") VALUES\n"
61
+
62
+ rows.each_with_index do |row, row_index|
63
+ buffer << ",\n" if row_index.positive?
64
+ buffer << " ("
65
+ column_formats.each_with_index do |format, column_index|
66
+ buffer << ", " if column_index.positive?
67
+ buffer << format_value(row[format[:name]], format)
68
+ end
69
+ buffer << ")"
70
+ end
71
+ buffer << ";\n"
72
+ out.write(buffer)
73
+ end
74
+
75
+ def format_value(value, format)
76
+ allows_null = format[:allows_null]
77
+ if value.nil? || value == "null"
78
+ return "NULL" if allows_null
79
+ return "0" if format[:numeric]
80
+
81
+ return "''"
82
+ end
83
+
84
+ if format[:numeric]
85
+ return allows_null ? "NULL" : "0" if value == ""
86
+ return value if value.to_s.match?(/\A-?\d+(?:\.\d+)?\z/)
87
+ end
88
+
89
+ # Repeated empty strings violate nullable unique indexes.
90
+ return "NULL" if format[:unique] && allows_null && value == ""
91
+
92
+ SqlText.escape_sql_string(value)
93
+ end
94
+
95
+ private
96
+
97
+ def numeric_type?(type)
98
+ type.match?(
99
+ /\A(?:int|bigint|tinyint|smallint|mediumint|float|double|decimal|numeric)/i
100
+ )
101
+ end
102
+ end
103
+ end
104
+ end