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,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../sql_text"
|
|
4
|
+
|
|
5
|
+
module StructuredDataToSql
|
|
6
|
+
module MysqlDumpXml
|
|
7
|
+
class TableStructure
|
|
8
|
+
attr_reader :name, :columns, :primary_keys, :indexes, :unique_keys
|
|
9
|
+
|
|
10
|
+
def initialize(name)
|
|
11
|
+
@name = name
|
|
12
|
+
@columns = []
|
|
13
|
+
@primary_keys = []
|
|
14
|
+
@indexes = {}
|
|
15
|
+
@unique_keys = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add_column(attrs)
|
|
19
|
+
col = {
|
|
20
|
+
name: attrs["Field"].to_s,
|
|
21
|
+
type: attrs["Type"] || "text",
|
|
22
|
+
null: attrs["Null"] == "YES",
|
|
23
|
+
key: attrs["Key"].to_s,
|
|
24
|
+
default: attrs["Default"],
|
|
25
|
+
extra: attrs["Extra"].to_s
|
|
26
|
+
}
|
|
27
|
+
@columns << col
|
|
28
|
+
case col[:key]
|
|
29
|
+
when "PRI"
|
|
30
|
+
@primary_keys << col[:name]
|
|
31
|
+
when "UNI"
|
|
32
|
+
@unique_keys["uk_#{col[:name]}"] = [col[:name]]
|
|
33
|
+
when "MUL"
|
|
34
|
+
@indexes["idx_#{col[:name]}"] = [col[:name]]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def column_names
|
|
39
|
+
@columns.map { |col| col[:name] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_create_table_sql
|
|
43
|
+
lines =
|
|
44
|
+
@columns.map do |col|
|
|
45
|
+
definition =
|
|
46
|
+
" #{SqlText.escape_identifier(col[:name])} #{col[:type]}"
|
|
47
|
+
definition += " NOT NULL" unless col[:null]
|
|
48
|
+
if col[:default] && col[:default] != "null"
|
|
49
|
+
default = col[:default]
|
|
50
|
+
definition +=
|
|
51
|
+
if default.start_with?("CURRENT_") || default == "NULL" ||
|
|
52
|
+
numeric_type?(col[:type])
|
|
53
|
+
" DEFAULT #{default}"
|
|
54
|
+
else
|
|
55
|
+
" DEFAULT #{SqlText.escape_sql_string(default)}"
|
|
56
|
+
end
|
|
57
|
+
elsif col[:null] && col[:default] == "null"
|
|
58
|
+
definition += " DEFAULT NULL"
|
|
59
|
+
end
|
|
60
|
+
if col[:extra].downcase.include?("auto_increment")
|
|
61
|
+
definition += " AUTO_INCREMENT"
|
|
62
|
+
elsif col[:extra].downcase.include?(
|
|
63
|
+
"on update current_timestamp"
|
|
64
|
+
) || col[:extra].include?("DEFAULT_GENERATED")
|
|
65
|
+
definition +=
|
|
66
|
+
" ON UPDATE CURRENT_TIMESTAMP" unless definition.upcase.include?(
|
|
67
|
+
"ON UPDATE CURRENT_TIMESTAMP"
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
definition
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if @primary_keys.any?
|
|
74
|
+
lines << " PRIMARY KEY (#{@primary_keys.map { |key| SqlText.escape_identifier(key) }.join(", ")})"
|
|
75
|
+
end
|
|
76
|
+
@unique_keys.each do |name, cols|
|
|
77
|
+
lines << " UNIQUE KEY #{SqlText.escape_identifier(name)} (#{cols.map { |col| SqlText.escape_identifier(col) }.join(", ")})"
|
|
78
|
+
end
|
|
79
|
+
@indexes.each do |name, cols|
|
|
80
|
+
lines << " KEY #{SqlText.escape_identifier(name)} (#{cols.map { |col| SqlText.escape_identifier(col) }.join(", ")})"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
"DROP TABLE IF EXISTS #{SqlText.escape_identifier(@name)};\n" \
|
|
84
|
+
"CREATE TABLE #{SqlText.escape_identifier(@name)} (\n" \
|
|
85
|
+
"#{lines.join(",\n")}\n" \
|
|
86
|
+
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;\n"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def numeric_type?(type)
|
|
92
|
+
type.match?(
|
|
93
|
+
/\A(?:int|bigint|tinyint|smallint|mediumint|float|double|decimal|numeric)/i
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StructuredDataToSql
|
|
4
|
+
class Options
|
|
5
|
+
attr_reader :values
|
|
6
|
+
|
|
7
|
+
def initialize(**values)
|
|
8
|
+
@values = defaults.merge(values).freeze
|
|
9
|
+
validate!
|
|
10
|
+
freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_h
|
|
14
|
+
@values.dup
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def defaults
|
|
20
|
+
{ batch_size: 1000, schema_only: false, verbose: false }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate!
|
|
24
|
+
unless @values[:batch_size].to_i.positive?
|
|
25
|
+
raise ConfigurationError, "Batch size must be greater than 0"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class JsonOptions < Options
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def defaults
|
|
34
|
+
super.merge(
|
|
35
|
+
max_depth: 5,
|
|
36
|
+
ndjson: :auto,
|
|
37
|
+
graphql_unwrap: true,
|
|
38
|
+
meta_table: true,
|
|
39
|
+
recover_truncated: false,
|
|
40
|
+
input_gzip: false,
|
|
41
|
+
output_gzip: nil
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate!
|
|
46
|
+
unless @values[:batch_size].to_i.positive?
|
|
47
|
+
raise ConfigurationError, "JSON batch size must be greater than 0"
|
|
48
|
+
end
|
|
49
|
+
unless @values[:max_depth].to_i.positive?
|
|
50
|
+
raise ConfigurationError, "JSON max depth must be greater than 0"
|
|
51
|
+
end
|
|
52
|
+
unless %i[auto json ndjson].include?(normalized_mode)
|
|
53
|
+
raise ConfigurationError,
|
|
54
|
+
"JSON input mode must be auto, json, or ndjson"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalized_mode
|
|
59
|
+
return :ndjson if @values[:ndjson] == true
|
|
60
|
+
return :json if @values[:ndjson] == false
|
|
61
|
+
|
|
62
|
+
@values[:ndjson].to_sym
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class XmlOptions < Options
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def defaults
|
|
70
|
+
super.merge(
|
|
71
|
+
scrub_invalid_xml_chars: true,
|
|
72
|
+
input_gzip: false,
|
|
73
|
+
output_gzip: nil
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate!
|
|
78
|
+
unless @values[:batch_size].to_i.positive?
|
|
79
|
+
raise ConfigurationError, "XML batch size must be greater than 0"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Json::Options = JsonOptions
|
|
85
|
+
Xml::Options = XmlOptions
|
|
86
|
+
|
|
87
|
+
# Compatibility alias for the original format-specific API.
|
|
88
|
+
MysqlDumpXmlOptions = XmlOptions
|
|
89
|
+
end
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
require_relative "format"
|
|
6
|
+
|
|
7
|
+
module StructuredDataToSql
|
|
8
|
+
# Renders converter progress events as terminal output on the diagnostic
|
|
9
|
+
# stream. It is purely a consumer of the throttled event stream the
|
|
10
|
+
# converters already emit, so it adds no per-record work: on a TTY it keeps
|
|
11
|
+
# one live status line updated in place; elsewhere it prints a plain status
|
|
12
|
+
# line at most every +min_interval+ seconds so logs show the run is alive
|
|
13
|
+
# without flooding. Finished files become fixed ledger rows, warnings are
|
|
14
|
+
# printed as they arrive, and a summary closes the run.
|
|
15
|
+
class ProgressReporter
|
|
16
|
+
TTY_INTERVAL = 0.5
|
|
17
|
+
PLAIN_INTERVAL = 30
|
|
18
|
+
ETA_WARMUP_SECONDS = 5
|
|
19
|
+
NAME_WIDTH = 28
|
|
20
|
+
PLAIN_WIDTH = 100
|
|
21
|
+
MIN_WRAP_WIDTH = 40
|
|
22
|
+
BLOCK_INDENT = " "
|
|
23
|
+
MAX_ITEM_LINES = 3
|
|
24
|
+
|
|
25
|
+
GLYPHS = {
|
|
26
|
+
unicode: {
|
|
27
|
+
done: "✓",
|
|
28
|
+
live: "⟳",
|
|
29
|
+
warn: "!"
|
|
30
|
+
},
|
|
31
|
+
ascii: {
|
|
32
|
+
done: "[ok]",
|
|
33
|
+
live: "[..]",
|
|
34
|
+
warn: "[!!]"
|
|
35
|
+
}
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
def initialize(
|
|
39
|
+
io:,
|
|
40
|
+
tty: nil,
|
|
41
|
+
min_interval: nil,
|
|
42
|
+
ascii: nil,
|
|
43
|
+
profile: nil,
|
|
44
|
+
clock: nil
|
|
45
|
+
)
|
|
46
|
+
@io = io
|
|
47
|
+
@tty = tty.nil? ? (io.respond_to?(:tty?) && io.tty?) : tty
|
|
48
|
+
@min_interval = min_interval || (@tty ? TTY_INTERVAL : PLAIN_INTERVAL)
|
|
49
|
+
@glyphs =
|
|
50
|
+
GLYPHS.fetch(
|
|
51
|
+
ascii.nil? ? (@tty ? :unicode : :ascii) : (ascii ? :ascii : :unicode)
|
|
52
|
+
)
|
|
53
|
+
@profile = profile
|
|
54
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
55
|
+
@live_line = nil
|
|
56
|
+
@last_status_at = nil
|
|
57
|
+
@total_input_bytes = nil
|
|
58
|
+
@last_line_blank = false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Recommended converter throttle so the event stream matches the display
|
|
62
|
+
# cadence; the converters only build a payload when this interval passes.
|
|
63
|
+
def converter_interval
|
|
64
|
+
@min_interval
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def call(event)
|
|
68
|
+
case event.type
|
|
69
|
+
when :start
|
|
70
|
+
start(event)
|
|
71
|
+
when :file_start
|
|
72
|
+
file_start(event)
|
|
73
|
+
when :pass_complete
|
|
74
|
+
@last_status_at = nil if @tty
|
|
75
|
+
status(event)
|
|
76
|
+
when :bytes, :rows, :table
|
|
77
|
+
status(event)
|
|
78
|
+
when :file_complete
|
|
79
|
+
file_complete(event)
|
|
80
|
+
when :warning
|
|
81
|
+
warning(event)
|
|
82
|
+
when :complete
|
|
83
|
+
complete(event)
|
|
84
|
+
end
|
|
85
|
+
rescue IOError, Errno::EPIPE
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def start(event)
|
|
92
|
+
files = event[:file_count]
|
|
93
|
+
total = event[:total_input_bytes]
|
|
94
|
+
@total_input_bytes = total
|
|
95
|
+
parts = ["Converting"]
|
|
96
|
+
parts << "#{Format.format_count(files)} file(s)" if files
|
|
97
|
+
parts << "· #{Format.format_size(total)}" if total.to_i.positive?
|
|
98
|
+
parts << "→ #{event[:output_path]}" if event[:output_path]
|
|
99
|
+
line(parts.join(" "))
|
|
100
|
+
line("Profile: #{@profile}") if @profile
|
|
101
|
+
line("")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A new file restarts the live line immediately; plain logs keep their
|
|
105
|
+
# steady cadence across file boundaries so small files add no lines.
|
|
106
|
+
def file_start(_event)
|
|
107
|
+
@last_status_at = nil if @tty
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def status(event)
|
|
111
|
+
file = event[:current_file]
|
|
112
|
+
return unless file
|
|
113
|
+
|
|
114
|
+
now = @clock.call
|
|
115
|
+
return if @last_status_at && now - @last_status_at < @min_interval
|
|
116
|
+
|
|
117
|
+
@last_status_at = now
|
|
118
|
+
text = status_text(event, file)
|
|
119
|
+
@tty ? show_live(text) : line(text)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def status_text(event, file)
|
|
123
|
+
parts = [" #{@glyphs[:live]} #{file[:name]} #{position(file)}".rstrip]
|
|
124
|
+
parts << "pass #{file[:pass]}/#{file[:passes]}" if file[:passes].to_i > 1
|
|
125
|
+
if file[:size].to_i.positive?
|
|
126
|
+
passes = [file[:passes].to_i, 1].max
|
|
127
|
+
pass_size = file[:size] / passes
|
|
128
|
+
pass_read =
|
|
129
|
+
file[:bytes_read].to_i - ((file[:pass].to_i - 1) * pass_size)
|
|
130
|
+
pass_read = pass_read.clamp(0, pass_size)
|
|
131
|
+
parts << percent(pass_read, pass_size)
|
|
132
|
+
parts << "#{Format.format_size(pass_read)}/#{Format.format_size(pass_size)}"
|
|
133
|
+
end
|
|
134
|
+
elapsed = event[:elapsed].to_f
|
|
135
|
+
work_read = event[:work_bytes_read]
|
|
136
|
+
work_total = event[:total_work_bytes]
|
|
137
|
+
rate = Format.format_rate(work_read, elapsed) if work_read
|
|
138
|
+
parts << rate if rate
|
|
139
|
+
tail = []
|
|
140
|
+
if work_total.to_i.positive? && work_read
|
|
141
|
+
tail << "overall #{percent(work_read, work_total)}"
|
|
142
|
+
end
|
|
143
|
+
tail << "#{Format.format_duration(elapsed)} elapsed"
|
|
144
|
+
eta = eta_text(work_read, work_total, elapsed)
|
|
145
|
+
tail << eta if eta
|
|
146
|
+
"#{parts.join(" ")} · #{tail.join(" · ")}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def eta_text(work_read, work_total, elapsed)
|
|
150
|
+
return nil unless work_total.to_i.positive? && work_read.to_i.positive?
|
|
151
|
+
return nil if elapsed < ETA_WARMUP_SECONDS
|
|
152
|
+
|
|
153
|
+
remaining = work_total - work_read
|
|
154
|
+
return nil if remaining <= 0
|
|
155
|
+
|
|
156
|
+
seconds = remaining * elapsed / work_read.to_f
|
|
157
|
+
return nil if seconds < 1
|
|
158
|
+
|
|
159
|
+
"~#{Format.format_duration(seconds)} left"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def file_complete(event)
|
|
163
|
+
file = event[:current_file] || {}
|
|
164
|
+
clear_live
|
|
165
|
+
size = file[:size].to_i / [file[:passes].to_i, 1].max
|
|
166
|
+
rows = event[:file_rows] || file[:record_count]
|
|
167
|
+
row_text = rows ? "#{Format.format_count(rows)} rows" : ""
|
|
168
|
+
row_text +=
|
|
169
|
+
" (+#{Format.format_count(event[:file_child_rows])} child)" if event[
|
|
170
|
+
:file_child_rows
|
|
171
|
+
].to_i.positive?
|
|
172
|
+
elapsed = event[:file_elapsed]
|
|
173
|
+
line(
|
|
174
|
+
format(
|
|
175
|
+
" %s %-#{NAME_WIDTH}s %10s %30s %8s",
|
|
176
|
+
@glyphs[:done],
|
|
177
|
+
file[:name].to_s,
|
|
178
|
+
size.positive? ? Format.format_size(size) : "",
|
|
179
|
+
row_text,
|
|
180
|
+
elapsed ? Format.format_duration(elapsed) : ""
|
|
181
|
+
).rstrip
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# A structured diagnostic renders as a block: title with headline count,
|
|
186
|
+
# wrapped summary, an abbreviated item list, detail lines, and the action.
|
|
187
|
+
# The complete lists live in the diagnostics report. Plain warnings keep
|
|
188
|
+
# the single line.
|
|
189
|
+
def warning(event)
|
|
190
|
+
clear_live
|
|
191
|
+
diagnostic = event[:diagnostic]
|
|
192
|
+
if diagnostic.nil? || diagnostic.code == :generic
|
|
193
|
+
return line(" #{@glyphs[:warn]} #{event[:message]}")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
title = " #{@glyphs[:warn]} #{diagnostic.title}"
|
|
197
|
+
title += " (#{Format.format_count(diagnostic.count)})" if diagnostic.count
|
|
198
|
+
line(title)
|
|
199
|
+
wrap(diagnostic.summary).each { |text| line(text) } if diagnostic.summary
|
|
200
|
+
item_lines(diagnostic).each { |text| line(text) }
|
|
201
|
+
diagnostic.details.each do |detail|
|
|
202
|
+
wrap(detail, first_prefix: BLOCK_INDENT).each { |text| line(text) }
|
|
203
|
+
end
|
|
204
|
+
if diagnostic.action
|
|
205
|
+
wrap(
|
|
206
|
+
diagnostic.action,
|
|
207
|
+
first_prefix: "#{BLOCK_INDENT}Action: "
|
|
208
|
+
).each { |text| line(text) }
|
|
209
|
+
end
|
|
210
|
+
line("")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def item_lines(diagnostic)
|
|
214
|
+
return [] if diagnostic.items.empty?
|
|
215
|
+
unless diagnostic.terminal_items
|
|
216
|
+
return [
|
|
217
|
+
"#{BLOCK_INDENT}#{diagnostic.items_label || "Items"}: #{Format.format_count(diagnostic.items.length)} listed in the diagnostics report"
|
|
218
|
+
]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
label = "#{diagnostic.items_label || "Items"}: "
|
|
222
|
+
lines =
|
|
223
|
+
wrap(diagnostic.items.join(", "), first_prefix: BLOCK_INDENT + label)
|
|
224
|
+
return lines if lines.length <= MAX_ITEM_LINES
|
|
225
|
+
|
|
226
|
+
width = wrap_width
|
|
227
|
+
shown = lines.first(MAX_ITEM_LINES)
|
|
228
|
+
kept = shown.sum { |text| text.scan(/[^,\s]+(?=,|\z)/).length }
|
|
229
|
+
last = shown[-1].sub(/,?\s*\z/, "")
|
|
230
|
+
loop do
|
|
231
|
+
suffix =
|
|
232
|
+
", … (+#{Format.format_count(diagnostic.items.length - kept)} more)"
|
|
233
|
+
break if (last + suffix).length <= width || !last.include?(", ")
|
|
234
|
+
|
|
235
|
+
last = last.sub(/,\s*[^,]*\z/, "")
|
|
236
|
+
kept -= 1
|
|
237
|
+
end
|
|
238
|
+
shown[
|
|
239
|
+
-1
|
|
240
|
+
] = "#{last}, … (+#{Format.format_count(diagnostic.items.length - kept)} more)"
|
|
241
|
+
shown
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def wrap_width
|
|
245
|
+
[(terminal_width || PLAIN_WIDTH), MIN_WRAP_WIDTH].max
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def wrap(text, first_prefix: BLOCK_INDENT)
|
|
249
|
+
width = wrap_width
|
|
250
|
+
continuation = " " * first_prefix.length
|
|
251
|
+
lines = []
|
|
252
|
+
current = first_prefix.dup
|
|
253
|
+
text
|
|
254
|
+
.to_s
|
|
255
|
+
.split(/\s+/)
|
|
256
|
+
.each do |word|
|
|
257
|
+
if current.length > first_prefix.length &&
|
|
258
|
+
current.length + 1 + word.length > width
|
|
259
|
+
lines << current
|
|
260
|
+
current = continuation.dup
|
|
261
|
+
end
|
|
262
|
+
current << (current.length > continuation.length ? " " : "") << word
|
|
263
|
+
end
|
|
264
|
+
lines << current
|
|
265
|
+
lines
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def complete(event)
|
|
269
|
+
clear_live
|
|
270
|
+
line("") unless @last_line_blank
|
|
271
|
+
head = ["Complete:"]
|
|
272
|
+
if event[:files_processed]
|
|
273
|
+
head << "#{Format.format_count(event[:files_processed])} file(s),"
|
|
274
|
+
end
|
|
275
|
+
rows = "#{Format.format_count(event[:rows_processed])} rows"
|
|
276
|
+
if event[:child_rows_processed].to_i.positive?
|
|
277
|
+
rows +=
|
|
278
|
+
" (+#{Format.format_count(event[:child_rows_processed])} child rows)"
|
|
279
|
+
end
|
|
280
|
+
head << rows
|
|
281
|
+
if event[:tables_processed]
|
|
282
|
+
head[-1] += ","
|
|
283
|
+
head << "#{Format.format_count(event[:tables_processed])} tables"
|
|
284
|
+
end
|
|
285
|
+
line(head.join(" "))
|
|
286
|
+
|
|
287
|
+
details = []
|
|
288
|
+
read = @total_input_bytes || event[:bytes_read]
|
|
289
|
+
details << "read #{Format.format_size(read)}" if read
|
|
290
|
+
if event[:bytes_written]
|
|
291
|
+
details << "wrote #{Format.format_size(event[:bytes_written])}"
|
|
292
|
+
end
|
|
293
|
+
details << Format.format_duration(event[:elapsed]) if event[:elapsed]
|
|
294
|
+
warnings = event[:warnings_count].to_i
|
|
295
|
+
details << "#{warnings} warning(s)#{warnings.positive? ? " (see above)" : ""}"
|
|
296
|
+
line(" #{details.join(" · ")}")
|
|
297
|
+
if event[:diagnostics_report]
|
|
298
|
+
line("Full diagnostics: #{event[:diagnostics_report]}")
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def position(file)
|
|
303
|
+
return "" unless file[:index] && file[:count]
|
|
304
|
+
|
|
305
|
+
"(#{file[:index]}/#{file[:count]})"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def percent(part, whole)
|
|
309
|
+
return "" unless whole.to_i.positive?
|
|
310
|
+
|
|
311
|
+
value = [part.to_f * 100.0 / whole, 100.0].min
|
|
312
|
+
format("%d%%", value)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def show_live(text)
|
|
316
|
+
width = terminal_width
|
|
317
|
+
if width && width > 20 && text.length >= width
|
|
318
|
+
text = "#{text[0, width - 2]}…"
|
|
319
|
+
end
|
|
320
|
+
@io.write("\r#{text}\e[K")
|
|
321
|
+
@io.flush if @io.respond_to?(:flush)
|
|
322
|
+
@live_line = text
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def clear_live
|
|
326
|
+
return unless @live_line
|
|
327
|
+
|
|
328
|
+
@io.write("\r\e[K")
|
|
329
|
+
@live_line = nil
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def line(text)
|
|
333
|
+
clear_live
|
|
334
|
+
@io.puts(text)
|
|
335
|
+
@io.flush if @io.respond_to?(:flush)
|
|
336
|
+
@last_line_blank = text.to_s.empty?
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def terminal_width
|
|
340
|
+
return nil unless @tty && @io.respond_to?(:winsize)
|
|
341
|
+
|
|
342
|
+
width = @io.winsize[1].to_i
|
|
343
|
+
width.positive? ? width : nil
|
|
344
|
+
rescue StandardError
|
|
345
|
+
nil
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StructuredDataToSql
|
|
4
|
+
# MySQL/MariaDB literal and identifier escaping shared by the XML and JSON
|
|
5
|
+
# dump converters.
|
|
6
|
+
module SqlText
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def escape_sql_string(value)
|
|
10
|
+
return "NULL" if value.nil?
|
|
11
|
+
|
|
12
|
+
escaped =
|
|
13
|
+
value
|
|
14
|
+
.to_s
|
|
15
|
+
.gsub("\\", "\\\\\\")
|
|
16
|
+
.gsub("'", "\\\\'")
|
|
17
|
+
.gsub("\n", "\\n")
|
|
18
|
+
.gsub("\r", "\\r")
|
|
19
|
+
.gsub("\t", "\\t")
|
|
20
|
+
.gsub("\x00", "\\0")
|
|
21
|
+
.gsub("\x1a", "\\Z")
|
|
22
|
+
"'#{escaped}'"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def escape_identifier(name)
|
|
26
|
+
"`#{name.to_s.gsub("`", "``")}`"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|