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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0ccb8d7b174afed7679305fa1e49d6892bbe7000d3816e01694aad2390773bc3
|
|
4
|
+
data.tar.gz: 522028f6c1f0935a9e58e52730c46c7682863fd3431b2ffd78bca9f74f6a6718
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9c3c997b1c817e2536deb6bdd0a86c945101b95a6d46f37cd8dbd875b3f1010f6f7538fe631edbb999fc56c105a632634fd67dd8fcccb761c3777b8eccd8ea4f
|
|
7
|
+
data.tar.gz: ca3f01ad1ac74c4720e4991f720f377c533076f1d6b54c0d4f2f0e2b3797e67e668e6fff26e249bae28a5c3b5df507b9c9b988fcda39ca8f49fe23644589f8bc
|
data/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Civilized Discourse Construction Kit, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "../structured_data_to_sql"
|
|
5
|
+
|
|
6
|
+
module StructuredDataToSql
|
|
7
|
+
class CLI
|
|
8
|
+
def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
9
|
+
new(stdin:, stdout:, stderr:).run(argv)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
13
|
+
@stdin = stdin
|
|
14
|
+
@stdout = stdout
|
|
15
|
+
@stderr = stderr
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run(argv)
|
|
19
|
+
command = argv.shift
|
|
20
|
+
return print_help if command.nil? || %w[-h --help help].include?(command)
|
|
21
|
+
return print_version if %w[-v --version version].include?(command)
|
|
22
|
+
|
|
23
|
+
case command
|
|
24
|
+
when "json"
|
|
25
|
+
convert_json(argv)
|
|
26
|
+
when "xml", "mysql-xml"
|
|
27
|
+
convert_xml(argv)
|
|
28
|
+
else
|
|
29
|
+
raise UsageError,
|
|
30
|
+
"Unknown command #{command.inspect}; expected json or xml"
|
|
31
|
+
end
|
|
32
|
+
0
|
|
33
|
+
rescue Json::ParseError, MysqlDumpXml::ParseError => e
|
|
34
|
+
@stderr.puts("error: #{e.message}")
|
|
35
|
+
1
|
|
36
|
+
rescue ConfigurationError, OptionParser::ParseError => e
|
|
37
|
+
@stderr.puts("error: #{e.message}")
|
|
38
|
+
2
|
|
39
|
+
rescue Error, SystemCallError, IOError, Zlib::Error => e
|
|
40
|
+
@stderr.puts("error: #{e.message}")
|
|
41
|
+
1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def convert_json(argv)
|
|
47
|
+
options = common_options
|
|
48
|
+
options.merge!(max_depth: JsonConverter::DEFAULT_MAX_DEPTH, ndjson: :auto)
|
|
49
|
+
parser =
|
|
50
|
+
OptionParser.new do |opts|
|
|
51
|
+
opts.banner =
|
|
52
|
+
"Usage: structured-data-to-sql json SOURCE --output FILE [options]"
|
|
53
|
+
add_common_options(opts, options)
|
|
54
|
+
opts.on(
|
|
55
|
+
"--profile NAME",
|
|
56
|
+
"Preparation profile (khoros-api-export)"
|
|
57
|
+
) { |v| options[:profile] = v }
|
|
58
|
+
opts.on("--records-path PATH", "Dot path containing records") do |v|
|
|
59
|
+
options[:records_path] = v
|
|
60
|
+
end
|
|
61
|
+
opts.on(
|
|
62
|
+
"--records-path-config FILE",
|
|
63
|
+
"Per-file records paths (YAML/JSON)"
|
|
64
|
+
) { |v| options[:records_path_config] = v }
|
|
65
|
+
opts.on("--table-name NAME", "Override the root table name") do |v|
|
|
66
|
+
options[:table_name] = v
|
|
67
|
+
end
|
|
68
|
+
opts.on(
|
|
69
|
+
"--max-depth N",
|
|
70
|
+
Integer,
|
|
71
|
+
"Maximum nested object depth"
|
|
72
|
+
) { |v| options[:max_depth] = v }
|
|
73
|
+
opts.on("--schema-dir DIR", "JSON Schema directory") do |v|
|
|
74
|
+
options[:schema_dir] = v
|
|
75
|
+
end
|
|
76
|
+
opts.on("--[no-]meta-table", "Emit JSON path metadata") do |v|
|
|
77
|
+
options[:meta_table] = v
|
|
78
|
+
end
|
|
79
|
+
opts.on("--ndjson", "Treat input as NDJSON") do
|
|
80
|
+
options[:ndjson] = true
|
|
81
|
+
end
|
|
82
|
+
opts.on("--no-ndjson", "Treat input as one JSON document") do
|
|
83
|
+
options[:ndjson] = false
|
|
84
|
+
end
|
|
85
|
+
opts.on(
|
|
86
|
+
"--json-columns LIST",
|
|
87
|
+
Array,
|
|
88
|
+
"Force paths to JSON columns"
|
|
89
|
+
) { |v| options[:json_columns] = v }
|
|
90
|
+
opts.on("--[no-]graphql-unwrap", "Unwrap GraphQL nodes") do |v|
|
|
91
|
+
options[:graphql_unwrap] = v
|
|
92
|
+
end
|
|
93
|
+
opts.on("--raw-dates", "Do not infer DATETIME columns") do
|
|
94
|
+
options[:raw_dates] = true
|
|
95
|
+
end
|
|
96
|
+
opts.on(
|
|
97
|
+
"--recover-truncated",
|
|
98
|
+
"Recover complete records from truncated input"
|
|
99
|
+
) { options[:recover_truncated] = true }
|
|
100
|
+
end
|
|
101
|
+
parsed = parse_source_and_output(parser, argv, options)
|
|
102
|
+
return unless parsed
|
|
103
|
+
|
|
104
|
+
source, output = parsed
|
|
105
|
+
handler = progress_handler(options)
|
|
106
|
+
JsonConverter.new(**converter_options(options)).convert(
|
|
107
|
+
source: source,
|
|
108
|
+
output: output,
|
|
109
|
+
file_pattern: options[:pattern] || "*.json",
|
|
110
|
+
atomic: true,
|
|
111
|
+
on_progress: handler,
|
|
112
|
+
**progress_interval_option(handler)
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def convert_xml(argv)
|
|
117
|
+
return inspect_xml(argv.drop(1)) if argv.first == "inspect"
|
|
118
|
+
|
|
119
|
+
options = common_options
|
|
120
|
+
options[:scrub_invalid_xml_chars] = true
|
|
121
|
+
parser =
|
|
122
|
+
OptionParser.new do |opts|
|
|
123
|
+
opts.banner =
|
|
124
|
+
"Usage: structured-data-to-sql xml SOURCE --output FILE [options]"
|
|
125
|
+
add_common_options(opts, options)
|
|
126
|
+
opts.on(
|
|
127
|
+
"--[no-]scrub-invalid-xml-chars",
|
|
128
|
+
"Repair invalid XML characters"
|
|
129
|
+
) { |v| options[:scrub_invalid_xml_chars] = v }
|
|
130
|
+
opts.on(
|
|
131
|
+
"--invalid-xml-report FILE",
|
|
132
|
+
"Write invalid-character report"
|
|
133
|
+
) { |v| options[:invalid_xml_report_path] = v }
|
|
134
|
+
end
|
|
135
|
+
parsed = parse_source_and_output(parser, argv, options)
|
|
136
|
+
return unless parsed
|
|
137
|
+
|
|
138
|
+
source, output = parsed
|
|
139
|
+
handler = progress_handler(options)
|
|
140
|
+
XmlConverter.new(**converter_options(options)).convert(
|
|
141
|
+
source: source,
|
|
142
|
+
output: output,
|
|
143
|
+
file_pattern: options[:pattern] || "*.xml",
|
|
144
|
+
atomic: true,
|
|
145
|
+
on_progress: handler,
|
|
146
|
+
**progress_interval_option(handler)
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def inspect_xml(argv)
|
|
151
|
+
options = {
|
|
152
|
+
include_files: nil,
|
|
153
|
+
exclude_files: [],
|
|
154
|
+
input_gzip: false,
|
|
155
|
+
verbose: true
|
|
156
|
+
}
|
|
157
|
+
parser =
|
|
158
|
+
OptionParser.new do |opts|
|
|
159
|
+
opts.banner =
|
|
160
|
+
"Usage: structured-data-to-sql xml inspect SOURCE [options]"
|
|
161
|
+
opts.on("--pattern GLOB", "Input filename pattern") do |value|
|
|
162
|
+
options[:pattern] = value
|
|
163
|
+
end
|
|
164
|
+
opts.on(
|
|
165
|
+
"--include-files LIST",
|
|
166
|
+
Array,
|
|
167
|
+
"Only these source files"
|
|
168
|
+
) { |value| options[:include_files] = value }
|
|
169
|
+
opts.on(
|
|
170
|
+
"--exclude-files LIST",
|
|
171
|
+
Array,
|
|
172
|
+
"Skip these source files"
|
|
173
|
+
) { |value| options[:exclude_files] = value }
|
|
174
|
+
opts.on("--input-gzip", "Read stdin/IO as gzip") do
|
|
175
|
+
options[:input_gzip] = true
|
|
176
|
+
end
|
|
177
|
+
opts.on("--quiet", "Suppress progress output") do
|
|
178
|
+
options[:verbose] = false
|
|
179
|
+
end
|
|
180
|
+
opts.on_tail("-h", "--help", "Show this help") do
|
|
181
|
+
options[:help] = true
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
parser.parse!(argv)
|
|
185
|
+
if options.delete(:help)
|
|
186
|
+
@stdout.puts(parser)
|
|
187
|
+
return
|
|
188
|
+
end
|
|
189
|
+
raise UsageError, parser.banner unless argv.length == 1
|
|
190
|
+
|
|
191
|
+
source_argument = argv.first
|
|
192
|
+
source = source_argument == "-" ? @stdin : source_argument
|
|
193
|
+
discovery =
|
|
194
|
+
Xml::TableDiscovery.new(
|
|
195
|
+
include_files: options[:include_files],
|
|
196
|
+
exclude_files: options[:exclude_files]
|
|
197
|
+
).discover(
|
|
198
|
+
source,
|
|
199
|
+
file_pattern: options[:pattern] || "*.xml",
|
|
200
|
+
input_gzip: options[:input_gzip],
|
|
201
|
+
progress_callback: xml_inspection_progress_handler(options)
|
|
202
|
+
)
|
|
203
|
+
print_xml_inspection(discovery, source_argument)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def print_xml_inspection(discovery, source)
|
|
207
|
+
tables =
|
|
208
|
+
discovery.tables.sort_by do |table|
|
|
209
|
+
metadata = discovery.table_metadata[table] || {}
|
|
210
|
+
[
|
|
211
|
+
-metadata[:xml_data_bytes].to_i,
|
|
212
|
+
-Xml::TableDiscovery.total_size(metadata),
|
|
213
|
+
-metadata[:rows].to_i,
|
|
214
|
+
table
|
|
215
|
+
]
|
|
216
|
+
end
|
|
217
|
+
table_width = ["TABLE".length, tables.map(&:length).max.to_i].max
|
|
218
|
+
|
|
219
|
+
@stdout.puts("XML table inspection")
|
|
220
|
+
@stdout.puts("Source: #{source}")
|
|
221
|
+
@stdout.puts(
|
|
222
|
+
"Files: #{discovery.files.length} selected, #{discovery.files_skipped} skipped"
|
|
223
|
+
)
|
|
224
|
+
@stdout.puts("Scanned: #{Format.format_size(discovery.bytes_scanned)}")
|
|
225
|
+
@stdout.puts("Tables: #{tables.length}")
|
|
226
|
+
@stdout.puts
|
|
227
|
+
@stdout.puts(
|
|
228
|
+
format(
|
|
229
|
+
"%-#{table_width}s %12s %12s %12s %12s %12s",
|
|
230
|
+
"TABLE",
|
|
231
|
+
"XML DATA",
|
|
232
|
+
"ROWS",
|
|
233
|
+
"DB DATA",
|
|
234
|
+
"DB INDEX",
|
|
235
|
+
"DB TOTAL"
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
tables.each do |table|
|
|
239
|
+
metadata = discovery.table_metadata[table] || {}
|
|
240
|
+
@stdout.puts(
|
|
241
|
+
format(
|
|
242
|
+
"%-#{table_width}s %12s %12s %12s %12s %12s",
|
|
243
|
+
table,
|
|
244
|
+
Format.format_size(metadata[:xml_data_bytes].to_i),
|
|
245
|
+
metadata[:rows] || "n/a",
|
|
246
|
+
optional_size(metadata[:data_length]),
|
|
247
|
+
optional_size(metadata[:index_length]),
|
|
248
|
+
database_total_size(metadata)
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
@stdout.puts
|
|
253
|
+
@stdout.puts("Convert with exclusions:")
|
|
254
|
+
@stdout.puts(
|
|
255
|
+
" structured-data-to-sql xml SOURCE --exclude-tables TABLE1,TABLE2 --output converted.sql.gz"
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def optional_size(bytes)
|
|
260
|
+
bytes.nil? ? "n/a" : Format.format_size(bytes)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def database_total_size(metadata)
|
|
264
|
+
return "n/a" unless Xml::TableDiscovery.size_metadata?(metadata)
|
|
265
|
+
|
|
266
|
+
Format.format_size(Xml::TableDiscovery.total_size(metadata))
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def xml_inspection_progress_handler(options)
|
|
270
|
+
return nil unless options[:verbose]
|
|
271
|
+
|
|
272
|
+
lambda do |progress|
|
|
273
|
+
case progress[:event]
|
|
274
|
+
when :file_start
|
|
275
|
+
@stderr.puts("inspecting #{progress[:current_file][:name]}")
|
|
276
|
+
when :bytes
|
|
277
|
+
scanned = progress[:bytes_scanned].to_i
|
|
278
|
+
total = progress[:total_input_bytes].to_i
|
|
279
|
+
elapsed = progress[:elapsed].to_f
|
|
280
|
+
percent =
|
|
281
|
+
total.positive? ? format("%.1f%%", scanned * 100.0 / total) : "n/a"
|
|
282
|
+
rate = elapsed.positive? ? scanned / elapsed : 0
|
|
283
|
+
file = progress[:current_file]&.fetch(:name, nil) || "n/a"
|
|
284
|
+
@stderr.puts(
|
|
285
|
+
"inspection: #{Format.format_size(scanned)}/#{Format.format_size(total)} " \
|
|
286
|
+
"(#{percent}), #{Format.format_size(rate)}/s, #{file}, " \
|
|
287
|
+
"#{progress[:tables].to_i} table(s)"
|
|
288
|
+
)
|
|
289
|
+
when :complete
|
|
290
|
+
@stderr.puts("inspection complete: #{progress[:tables]} table(s)")
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def common_options
|
|
296
|
+
{
|
|
297
|
+
batch_size: 1000,
|
|
298
|
+
include_files: nil,
|
|
299
|
+
exclude_files: [],
|
|
300
|
+
include_tables: nil,
|
|
301
|
+
exclude_tables: [],
|
|
302
|
+
schema_only: false,
|
|
303
|
+
verbose: true,
|
|
304
|
+
input_gzip: false,
|
|
305
|
+
output_gzip: nil
|
|
306
|
+
}
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def add_common_options(parser, options)
|
|
310
|
+
parser.on("-o", "--output FILE", "Output .sql or .sql.gz file") do |v|
|
|
311
|
+
options[:output] = v
|
|
312
|
+
end
|
|
313
|
+
parser.on("--pattern GLOB", "Input filename pattern") do |v|
|
|
314
|
+
options[:pattern] = v
|
|
315
|
+
end
|
|
316
|
+
parser.on("--batch-size N", Integer, "Rows per INSERT") do |v|
|
|
317
|
+
options[:batch_size] = v
|
|
318
|
+
end
|
|
319
|
+
parser.on("--include-files LIST", Array, "Only these source files") do |v|
|
|
320
|
+
options[:include_files] = v
|
|
321
|
+
end
|
|
322
|
+
parser.on("--exclude-files LIST", Array, "Skip these source files") do |v|
|
|
323
|
+
options[:exclude_files] = v
|
|
324
|
+
end
|
|
325
|
+
parser.on("--include-tables LIST", Array, "Only these tables") do |v|
|
|
326
|
+
options[:include_tables] = v
|
|
327
|
+
end
|
|
328
|
+
parser.on("--exclude-tables LIST", Array, "Skip these tables") do |v|
|
|
329
|
+
options[:exclude_tables] = v
|
|
330
|
+
end
|
|
331
|
+
parser.on("--schema-only", "Emit DDL without rows") do
|
|
332
|
+
options[:schema_only] = true
|
|
333
|
+
end
|
|
334
|
+
parser.on("--quiet", "Suppress progress output") do
|
|
335
|
+
options[:verbose] = false
|
|
336
|
+
end
|
|
337
|
+
parser.on(
|
|
338
|
+
"--diagnostics-report FILE",
|
|
339
|
+
"Write the Markdown diagnostics report here (default: <output>.diagnostics.md)"
|
|
340
|
+
) { |v| options[:diagnostics_report] = v }
|
|
341
|
+
parser.on(
|
|
342
|
+
"--no-diagnostics-report",
|
|
343
|
+
"Do not write a diagnostics report"
|
|
344
|
+
) { options[:diagnostics_report] = false }
|
|
345
|
+
parser.on("--input-gzip", "Read stdin/IO as gzip") do
|
|
346
|
+
options[:input_gzip] = true
|
|
347
|
+
end
|
|
348
|
+
parser.on("--output-gzip", "Write stdout/IO as gzip") do
|
|
349
|
+
options[:output_gzip] = true
|
|
350
|
+
end
|
|
351
|
+
parser.on("--no-output-gzip", "Disable inferred output gzip") do
|
|
352
|
+
options[:output_gzip] = false
|
|
353
|
+
end
|
|
354
|
+
parser.on_tail("-h", "--help", "Show this help") { options[:help] = true }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def parse_source_and_output(parser, argv, options)
|
|
358
|
+
parser.parse!(argv)
|
|
359
|
+
if options.delete(:help)
|
|
360
|
+
@stdout.puts(parser)
|
|
361
|
+
return nil
|
|
362
|
+
end
|
|
363
|
+
raise UsageError, parser.banner unless argv.length == 1
|
|
364
|
+
raise UsageError, "--output is required" unless options[:output]
|
|
365
|
+
|
|
366
|
+
source = argv.first == "-" ? @stdin : argv.first
|
|
367
|
+
output = options[:output] == "-" ? @stdout : options[:output]
|
|
368
|
+
[source, output]
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def converter_options(options)
|
|
372
|
+
options.reject { |key, _| %i[output pattern].include?(key) }
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Match the converter's event throttle to the display cadence: payloads
|
|
376
|
+
# are only built when the reporter can show them.
|
|
377
|
+
def progress_interval_option(handler)
|
|
378
|
+
return {} unless handler.respond_to?(:converter_interval)
|
|
379
|
+
|
|
380
|
+
{ progress_interval: handler.converter_interval }
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Diagnostics go to stderr only, so SQL on stdout stays clean. The
|
|
384
|
+
# reporter consumes the converters' already-throttled event stream; the
|
|
385
|
+
# converter's own interval is matched to the display cadence so no
|
|
386
|
+
# payload is built more often than it can be shown.
|
|
387
|
+
def progress_handler(options)
|
|
388
|
+
return nil unless options[:verbose]
|
|
389
|
+
|
|
390
|
+
ProgressReporter.new(io: @stderr, profile: options[:profile])
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def print_help
|
|
394
|
+
@stdout.puts(
|
|
395
|
+
"Usage: structured-data-to-sql COMMAND [options]\n\nCommands:\n json Convert JSON/NDJSON to MariaDB SQL\n xml Convert mysqldump --xml to MariaDB SQL\n xml inspect Inspect XML table sizes without converting"
|
|
396
|
+
)
|
|
397
|
+
0
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def print_version
|
|
401
|
+
@stdout.puts("structured-data-to-sql #{VERSION}")
|
|
402
|
+
0
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StructuredDataToSql
|
|
4
|
+
module ImmutableValue
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def deep_freeze(value)
|
|
8
|
+
case value
|
|
9
|
+
when Hash
|
|
10
|
+
value.each do |key, item|
|
|
11
|
+
deep_freeze(key)
|
|
12
|
+
deep_freeze(item)
|
|
13
|
+
end
|
|
14
|
+
when Array
|
|
15
|
+
value.each { |item| deep_freeze(item) }
|
|
16
|
+
end
|
|
17
|
+
value.freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class ConversionResult
|
|
22
|
+
include ImmutableValue
|
|
23
|
+
|
|
24
|
+
COMMON_KEYS = %i[
|
|
25
|
+
format
|
|
26
|
+
files_processed
|
|
27
|
+
files_skipped
|
|
28
|
+
tables_processed
|
|
29
|
+
tables_skipped
|
|
30
|
+
rows_processed
|
|
31
|
+
child_rows_processed
|
|
32
|
+
bytes_read
|
|
33
|
+
bytes_written
|
|
34
|
+
warnings
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
def initialize(format:, metrics: {})
|
|
38
|
+
values = metrics.transform_keys(&:to_sym).merge(format: format.to_sym)
|
|
39
|
+
COMMON_KEYS.each { |key| values[key] ||= key == :warnings ? [] : 0 }
|
|
40
|
+
@values = deep_freeze(values.dup)
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def [](key)
|
|
45
|
+
@values[key.to_sym]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def fetch(...)
|
|
49
|
+
@values.fetch(...)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_h
|
|
53
|
+
@values.dup
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def metrics
|
|
57
|
+
@values.reject { |key, _| COMMON_KEYS.include?(key) }.freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
COMMON_KEYS.each { |key| define_method(key) { @values[key] } }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class ProgressEvent
|
|
64
|
+
include ImmutableValue
|
|
65
|
+
|
|
66
|
+
attr_reader :type
|
|
67
|
+
|
|
68
|
+
def initialize(type, attributes = {})
|
|
69
|
+
@type = type.to_sym
|
|
70
|
+
@attributes = deep_freeze(attributes.transform_keys(&:to_sym))
|
|
71
|
+
freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
alias event type
|
|
75
|
+
|
|
76
|
+
def [](key)
|
|
77
|
+
key.to_sym == :event ? type : @attributes[key.to_sym]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_h
|
|
81
|
+
@attributes.merge(event: type)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def method_missing(name, *args)
|
|
85
|
+
return @attributes[name] if args.empty? && @attributes.key?(name)
|
|
86
|
+
|
|
87
|
+
super
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def respond_to_missing?(name, include_private = false)
|
|
91
|
+
@attributes.key?(name) || super
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "conversion_result"
|
|
4
|
+
|
|
5
|
+
module StructuredDataToSql
|
|
6
|
+
# A structured warning: the one-line +message+ that ConversionResult#warnings
|
|
7
|
+
# has always carried, plus the parts a terminal block or a report needs —
|
|
8
|
+
# a stable +code+, a short +title+, a headline +count+, a +summary+, the
|
|
9
|
+
# complete +items+ list (field names, ids), extra +details+ lines, a
|
|
10
|
+
# suggested +action+, and +sources+ (paths to look at). Nothing here holds
|
|
11
|
+
# member values or per-row payloads.
|
|
12
|
+
class Diagnostic
|
|
13
|
+
include ImmutableValue
|
|
14
|
+
|
|
15
|
+
attr_reader :code,
|
|
16
|
+
:title,
|
|
17
|
+
:count,
|
|
18
|
+
:summary,
|
|
19
|
+
:items,
|
|
20
|
+
:items_label,
|
|
21
|
+
:terminal_items,
|
|
22
|
+
:details,
|
|
23
|
+
:action,
|
|
24
|
+
:sources,
|
|
25
|
+
:message
|
|
26
|
+
|
|
27
|
+
def initialize(
|
|
28
|
+
code:,
|
|
29
|
+
title:,
|
|
30
|
+
message:,
|
|
31
|
+
count: nil,
|
|
32
|
+
summary: nil,
|
|
33
|
+
items: [],
|
|
34
|
+
items_label: nil,
|
|
35
|
+
terminal_items: true,
|
|
36
|
+
details: [],
|
|
37
|
+
action: nil,
|
|
38
|
+
sources: []
|
|
39
|
+
)
|
|
40
|
+
@code = code.to_sym
|
|
41
|
+
@title = title.to_s
|
|
42
|
+
@message = message.to_s
|
|
43
|
+
@count = count
|
|
44
|
+
@summary = summary
|
|
45
|
+
@items = deep_freeze(Array(items).map(&:to_s))
|
|
46
|
+
@items_label = items_label
|
|
47
|
+
@terminal_items = terminal_items ? true : false
|
|
48
|
+
@details = deep_freeze(Array(details).map(&:to_s))
|
|
49
|
+
@action = action
|
|
50
|
+
@sources = deep_freeze(Array(sources).map(&:to_s))
|
|
51
|
+
freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.generic(message)
|
|
55
|
+
new(code: :generic, title: "Warning", message: message)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.wrap(value)
|
|
59
|
+
value.is_a?(Diagnostic) ? value : generic(value.to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_s
|
|
63
|
+
message
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def to_h
|
|
67
|
+
{
|
|
68
|
+
code: code,
|
|
69
|
+
title: title,
|
|
70
|
+
count: count,
|
|
71
|
+
summary: summary,
|
|
72
|
+
items: items,
|
|
73
|
+
items_label: items_label,
|
|
74
|
+
terminal_items: terminal_items,
|
|
75
|
+
details: details,
|
|
76
|
+
action: action,
|
|
77
|
+
sources: sources,
|
|
78
|
+
message: message
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|