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.
- checksums.yaml +7 -0
- data/LICENSE +9 -0
- data/bin/structured-data-to-sql +6 -0
- data/lib/structured_data_to_sql/cli.rb +405 -0
- data/lib/structured_data_to_sql/conversion_result.rb +94 -0
- data/lib/structured_data_to_sql/diagnostic.rb +82 -0
- data/lib/structured_data_to_sql/diagnostics_report.rb +187 -0
- data/lib/structured_data_to_sql/errors.rb +48 -0
- data/lib/structured_data_to_sql/format.rb +41 -0
- data/lib/structured_data_to_sql/io_support.rb +154 -0
- data/lib/structured_data_to_sql/json/exporter_manifest.rb +127 -0
- data/lib/structured_data_to_sql/json/json_schema_loader.rb +310 -0
- data/lib/structured_data_to_sql/json/profiles/khoros_api_export.rb +1960 -0
- data/lib/structured_data_to_sql/json/profiles.rb +14 -0
- data/lib/structured_data_to_sql/json/record_streamer.rb +477 -0
- data/lib/structured_data_to_sql/json/schema_inferrer.rb +150 -0
- data/lib/structured_data_to_sql/json/shredder.rb +241 -0
- data/lib/structured_data_to_sql/json/sql_emitter.rb +198 -0
- data/lib/structured_data_to_sql/json_converter.rb +913 -0
- data/lib/structured_data_to_sql/mysql_dump_xml/invalid_character_report.rb +82 -0
- data/lib/structured_data_to_sql/mysql_dump_xml/sanitizer.rb +152 -0
- data/lib/structured_data_to_sql/mysql_dump_xml/sax_parser.rb +111 -0
- data/lib/structured_data_to_sql/mysql_dump_xml/sql_emitter.rb +104 -0
- data/lib/structured_data_to_sql/mysql_dump_xml/table_data_filter.rb +343 -0
- data/lib/structured_data_to_sql/mysql_dump_xml/table_discovery.rb +651 -0
- data/lib/structured_data_to_sql/mysql_dump_xml/table_structure.rb +98 -0
- data/lib/structured_data_to_sql/mysql_dump_xml_converter.rb +4 -0
- data/lib/structured_data_to_sql/options.rb +89 -0
- data/lib/structured_data_to_sql/progress_reporter.rb +348 -0
- data/lib/structured_data_to_sql/sql_text.rb +29 -0
- data/lib/structured_data_to_sql/version.rb +5 -0
- data/lib/structured_data_to_sql/xml_converter.rb +651 -0
- data/lib/structured_data_to_sql/xml_dump_converter.rb +4 -0
- data/lib/structured_data_to_sql.rb +54 -0
- 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, "&")
|
|
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
|