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,913 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require "time"
|
|
7
|
+
require "yaml"
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
require_relative "format"
|
|
10
|
+
require_relative "sql_text"
|
|
11
|
+
require_relative "conversion_result"
|
|
12
|
+
require_relative "io_support"
|
|
13
|
+
require_relative "options"
|
|
14
|
+
require_relative "json/record_streamer"
|
|
15
|
+
require_relative "json/shredder"
|
|
16
|
+
require_relative "json/schema_inferrer"
|
|
17
|
+
require_relative "json/json_schema_loader"
|
|
18
|
+
require_relative "json/sql_emitter"
|
|
19
|
+
require_relative "json/profiles"
|
|
20
|
+
require_relative "json/exporter_manifest"
|
|
21
|
+
require_relative "diagnostic"
|
|
22
|
+
require_relative "diagnostics_report"
|
|
23
|
+
|
|
24
|
+
module StructuredDataToSql
|
|
25
|
+
# Converts arbitrary JSON export files into a MySQL/MariaDB SQL dump using
|
|
26
|
+
# convention-based relational shredding. Counterpart to XmlConverter:
|
|
27
|
+
# same output framing, filters, gzip handling, atomic writes, and progress
|
|
28
|
+
# callback vocabulary. Each file is streamed twice: pass 1 infers the
|
|
29
|
+
# schema (CREATE TABLEs must precede INSERTs), pass 2 emits the rows.
|
|
30
|
+
class JsonConverter
|
|
31
|
+
attr_reader :stats
|
|
32
|
+
|
|
33
|
+
DEFAULT_PROGRESS_INTERVAL = 5
|
|
34
|
+
DEFAULT_MAX_DEPTH = 5
|
|
35
|
+
|
|
36
|
+
def initialize(
|
|
37
|
+
batch_size: 1000,
|
|
38
|
+
include_tables: nil,
|
|
39
|
+
exclude_tables: nil,
|
|
40
|
+
include_files: nil,
|
|
41
|
+
exclude_files: nil,
|
|
42
|
+
schema_only: false,
|
|
43
|
+
records_path: nil,
|
|
44
|
+
records_path_config: nil,
|
|
45
|
+
table_name: nil,
|
|
46
|
+
max_depth: DEFAULT_MAX_DEPTH,
|
|
47
|
+
json_columns: nil,
|
|
48
|
+
graphql_unwrap: true,
|
|
49
|
+
raw_dates: false,
|
|
50
|
+
schema_dir: nil,
|
|
51
|
+
meta_table: true,
|
|
52
|
+
recover_truncated: false,
|
|
53
|
+
ndjson: :auto,
|
|
54
|
+
input_mode: nil,
|
|
55
|
+
verbose: false,
|
|
56
|
+
input_gzip: false,
|
|
57
|
+
output_gzip: nil,
|
|
58
|
+
profile: nil,
|
|
59
|
+
diagnostic_io: nil,
|
|
60
|
+
diagnostics_report: nil
|
|
61
|
+
)
|
|
62
|
+
ndjson = { json: false, ndjson: true, auto: :auto }.fetch(
|
|
63
|
+
input_mode.to_sym
|
|
64
|
+
) if input_mode
|
|
65
|
+
JsonOptions.new(
|
|
66
|
+
batch_size:,
|
|
67
|
+
max_depth:,
|
|
68
|
+
ndjson:,
|
|
69
|
+
verbose:,
|
|
70
|
+
input_gzip:,
|
|
71
|
+
output_gzip:
|
|
72
|
+
)
|
|
73
|
+
unless batch_size.to_i.positive?
|
|
74
|
+
raise UsageError, "JSON batch size must be greater than 0"
|
|
75
|
+
end
|
|
76
|
+
unless max_depth.to_i.positive?
|
|
77
|
+
raise UsageError, "JSON max depth must be greater than 0"
|
|
78
|
+
end
|
|
79
|
+
if schema_dir && !Dir.exist?(schema_dir)
|
|
80
|
+
raise UsageError, "Schema directory not found: #{schema_dir}"
|
|
81
|
+
end
|
|
82
|
+
if records_path_config && !File.exist?(records_path_config)
|
|
83
|
+
raise UsageError,
|
|
84
|
+
"Records path config not found: #{records_path_config}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@profile = normalize_profile(profile)
|
|
88
|
+
if @profile && schema_only
|
|
89
|
+
raise UsageError, "--profile cannot be used with schema-only output"
|
|
90
|
+
end
|
|
91
|
+
if @profile && (include_tables || Array(exclude_tables).any?)
|
|
92
|
+
raise UsageError,
|
|
93
|
+
"--profile cannot be combined with table include/exclude filters"
|
|
94
|
+
end
|
|
95
|
+
if @profile && (records_path || records_path_config || table_name)
|
|
96
|
+
raise UsageError,
|
|
97
|
+
"--profile cannot be combined with records-path options or a root table override"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
@batch_size = batch_size
|
|
101
|
+
@include_tables = include_tables&.to_set
|
|
102
|
+
@exclude_tables = Array(exclude_tables).to_set
|
|
103
|
+
@include_files = include_files&.to_set
|
|
104
|
+
@exclude_files = Array(exclude_files).to_set
|
|
105
|
+
@schema_only = schema_only
|
|
106
|
+
@records_path = validate_records_path(records_path, "--records-path")
|
|
107
|
+
@records_path_config =
|
|
108
|
+
(
|
|
109
|
+
if records_path_config
|
|
110
|
+
load_records_path_config(records_path_config)
|
|
111
|
+
else
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
)
|
|
115
|
+
@table_name = table_name
|
|
116
|
+
@max_depth = max_depth
|
|
117
|
+
@json_columns = json_columns
|
|
118
|
+
@graphql_unwrap = graphql_unwrap
|
|
119
|
+
@raw_dates = raw_dates
|
|
120
|
+
@schema_dir = schema_dir
|
|
121
|
+
@meta_table = meta_table
|
|
122
|
+
@recover_truncated = recover_truncated
|
|
123
|
+
@ndjson = ndjson
|
|
124
|
+
@verbose = verbose
|
|
125
|
+
@input_gzip = input_gzip
|
|
126
|
+
@output_gzip = output_gzip
|
|
127
|
+
@diagnostic_io = diagnostic_io
|
|
128
|
+
@diagnostics_report = diagnostics_report
|
|
129
|
+
@emitter = Json::SqlEmitter.new
|
|
130
|
+
reset_stats
|
|
131
|
+
rescue StructuredDataToSql::ConfigurationError => e
|
|
132
|
+
raise Json::ConfigurationError, e.message
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def convert(
|
|
136
|
+
*arguments,
|
|
137
|
+
source: nil,
|
|
138
|
+
output: nil,
|
|
139
|
+
file_pattern: "*.json",
|
|
140
|
+
progress_callback: nil,
|
|
141
|
+
on_progress: nil,
|
|
142
|
+
atomic: true,
|
|
143
|
+
progress_interval: DEFAULT_PROGRESS_INTERVAL,
|
|
144
|
+
input_gzip: @input_gzip,
|
|
145
|
+
output_gzip: @output_gzip
|
|
146
|
+
)
|
|
147
|
+
source ||= arguments[0]
|
|
148
|
+
output ||= arguments[1]
|
|
149
|
+
raise ConfigurationError, "source is required" if source.nil?
|
|
150
|
+
raise ConfigurationError, "output is required" if output.nil?
|
|
151
|
+
IOSupport.validate_output_target!(output)
|
|
152
|
+
|
|
153
|
+
reset_stats
|
|
154
|
+
@progress_callback = progress_callback
|
|
155
|
+
@on_progress = on_progress
|
|
156
|
+
@progress_interval = progress_interval
|
|
157
|
+
@last_progress_at = nil
|
|
158
|
+
@current_file_progress = nil
|
|
159
|
+
@conversion_started_at = Time.now
|
|
160
|
+
@total_work_bytes = nil
|
|
161
|
+
@total_input_bytes = nil
|
|
162
|
+
@emitted_tables = {}
|
|
163
|
+
@profile_tables = {}
|
|
164
|
+
@profile_validator = profile_class::InputValidator.new if @profile
|
|
165
|
+
@pii_meta = []
|
|
166
|
+
@schema_used = false
|
|
167
|
+
@io_source = IOSupport.readable_io?(source)
|
|
168
|
+
source_label =
|
|
169
|
+
(
|
|
170
|
+
if IOSupport.readable_io?(source)
|
|
171
|
+
"(IO)"
|
|
172
|
+
else
|
|
173
|
+
Array(source).map(&:to_s).join(", ")
|
|
174
|
+
end
|
|
175
|
+
)
|
|
176
|
+
output_path = IOSupport.writable_io?(output) ? output : Pathname(output)
|
|
177
|
+
patterns = [
|
|
178
|
+
file_pattern,
|
|
179
|
+
"#{file_pattern}.gz",
|
|
180
|
+
"*.jsonl",
|
|
181
|
+
"*.jsonl.gz",
|
|
182
|
+
"*.ndjson",
|
|
183
|
+
"*.ndjson.gz"
|
|
184
|
+
].uniq
|
|
185
|
+
files, temporary_inputs =
|
|
186
|
+
IOSupport.discover(
|
|
187
|
+
source,
|
|
188
|
+
patterns:,
|
|
189
|
+
stream_extension: ".json",
|
|
190
|
+
input_gzip:
|
|
191
|
+
)
|
|
192
|
+
raise UsageError, "No JSON files found in #{source}" if files.empty?
|
|
193
|
+
|
|
194
|
+
files_to_process, skipped = files.partition { |file| process_file?(file) }
|
|
195
|
+
@stats[:files_skipped] += skipped.length
|
|
196
|
+
if files_to_process.empty?
|
|
197
|
+
raise UsageError,
|
|
198
|
+
"No JSON files to process after filtering. All #{files.length} files were excluded."
|
|
199
|
+
end
|
|
200
|
+
validate_profile_files!(files_to_process) if @profile
|
|
201
|
+
@manifest =
|
|
202
|
+
Json::ExporterManifest.load(Json::ExporterManifest.locate(source))
|
|
203
|
+
@source_paths = quality_signal_paths(files_to_process)
|
|
204
|
+
if @profile_validator.respond_to?(:run_context=)
|
|
205
|
+
@profile_validator.run_context = {
|
|
206
|
+
manifest: @manifest,
|
|
207
|
+
source_paths: @source_paths
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
log "\n#{"=" * 60}\nJSON TO SQL CONVERTER\n#{"=" * 60}"
|
|
212
|
+
log "\nSource: #{source_label}"
|
|
213
|
+
log "Output: #{output_path}"
|
|
214
|
+
log "Files found: #{files.length}"
|
|
215
|
+
log "Files to skip: #{skipped.length}" if skipped.any?
|
|
216
|
+
log "Files to process: #{files_to_process.length}"
|
|
217
|
+
total_input_bytes =
|
|
218
|
+
files_to_process.sum do |file|
|
|
219
|
+
begin
|
|
220
|
+
file.size
|
|
221
|
+
rescue StandardError
|
|
222
|
+
0
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
@total_input_bytes = total_input_bytes
|
|
226
|
+
@total_work_bytes =
|
|
227
|
+
files_to_process.sum do |file|
|
|
228
|
+
IOSupport.estimated_input_bytes(file) * streaming_passes_for(file)
|
|
229
|
+
end
|
|
230
|
+
report_progress(
|
|
231
|
+
:start,
|
|
232
|
+
file_count: files_to_process.length,
|
|
233
|
+
total_input_bytes: total_input_bytes,
|
|
234
|
+
output_path: output_path.to_s,
|
|
235
|
+
force: true
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
gzip_output =
|
|
239
|
+
(
|
|
240
|
+
if output_gzip.nil?
|
|
241
|
+
(!IOSupport.writable_io?(output) && output.to_s.end_with?(".gz"))
|
|
242
|
+
else
|
|
243
|
+
output_gzip
|
|
244
|
+
end
|
|
245
|
+
)
|
|
246
|
+
written_bytes =
|
|
247
|
+
IOSupport.with_output(output, gzip: gzip_output, atomic:) do |out|
|
|
248
|
+
@emitter.write_header(out, source_label)
|
|
249
|
+
write_profile_preamble(out) if @profile
|
|
250
|
+
files_to_process.each_with_index do |file, index|
|
|
251
|
+
convert_file(
|
|
252
|
+
file,
|
|
253
|
+
out,
|
|
254
|
+
index: index + 1,
|
|
255
|
+
count: files_to_process.length
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
if @schema_used && @meta_table
|
|
259
|
+
@emitter.write_meta_table(out, @pii_meta)
|
|
260
|
+
end
|
|
261
|
+
write_profile(out) if @profile
|
|
262
|
+
@emitter.write_footer(out)
|
|
263
|
+
end
|
|
264
|
+
@stats[:bytes_written] = written_bytes
|
|
265
|
+
write_diagnostics_report(source_label, output_path)
|
|
266
|
+
report_progress(
|
|
267
|
+
:complete,
|
|
268
|
+
bytes_written: @stats[:bytes_written],
|
|
269
|
+
bytes_read: @stats[:bytes_read],
|
|
270
|
+
diagnostics_report: @stats[:diagnostics_report],
|
|
271
|
+
force: true
|
|
272
|
+
)
|
|
273
|
+
log "\n#{"=" * 60}\nCONVERSION COMPLETE\n#{"=" * 60}"
|
|
274
|
+
log "\nFiles processed: #{@stats[:files_processed]}"
|
|
275
|
+
if @stats[:files_skipped].positive?
|
|
276
|
+
log "Files skipped: #{@stats[:files_skipped]}"
|
|
277
|
+
end
|
|
278
|
+
log "Tables converted: #{@stats[:tables_processed]}"
|
|
279
|
+
if @stats[:tables_skipped].positive?
|
|
280
|
+
log "Tables skipped: #{@stats[:tables_skipped]}"
|
|
281
|
+
end
|
|
282
|
+
log "Rows converted: #{@stats[:rows_processed]} (+#{@stats[:child_rows_processed]} child rows)"
|
|
283
|
+
log "Output size: #{Format.format_size(@stats[:bytes_written])}"
|
|
284
|
+
log "\nOutput written to: #{output_path}"
|
|
285
|
+
ConversionResult.new(format: :json, metrics: @stats)
|
|
286
|
+
rescue StructuredDataToSql::InputError => e
|
|
287
|
+
raise Json::InputError, e.message
|
|
288
|
+
rescue StructuredDataToSql::OutputError => e
|
|
289
|
+
raise Json::OutputError, e.message
|
|
290
|
+
rescue Oj::ParseError, EncodingError => e
|
|
291
|
+
raise Json::ParseError, e.message
|
|
292
|
+
rescue SystemCallError, IOError, Zlib::Error => e
|
|
293
|
+
raise InputError, e.message
|
|
294
|
+
ensure
|
|
295
|
+
temporary_inputs&.each(&:unlink)
|
|
296
|
+
@progress_callback = nil
|
|
297
|
+
@on_progress = nil
|
|
298
|
+
@io_source = nil
|
|
299
|
+
@current_file_progress = nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
private
|
|
303
|
+
|
|
304
|
+
def convert_file(path, out, index: 1, count: 1)
|
|
305
|
+
convert_file!(path, out, index: index, count: count)
|
|
306
|
+
rescue Oj::ParseError, EncodingError => e
|
|
307
|
+
base = File.basename(path.to_s).sub(/\.gz\z/, "").sub(/\.json\z/, "")
|
|
308
|
+
message =
|
|
309
|
+
"Malformed JSON in #{File.basename(path)} (#{e.message.sub(/ \[\S+\]\z/, "")})."
|
|
310
|
+
if truncated_json?(path)
|
|
311
|
+
message +=
|
|
312
|
+
" The file appears to be truncated — it does not end with '}' or ']'."
|
|
313
|
+
message +=
|
|
314
|
+
" Re-run with --recover-truncated to keep the complete records before the truncation point," \
|
|
315
|
+
" fix or re-export the file, or convert the others with --exclude-files #{base}."
|
|
316
|
+
else
|
|
317
|
+
message +=
|
|
318
|
+
" Fix or re-export the file, or convert the others with --exclude-files #{base}."
|
|
319
|
+
end
|
|
320
|
+
raise Json::ParseError, message
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# The last non-whitespace byte of a complete JSON document is always '}'
|
|
324
|
+
# or ']' for object/array roots (the only roots these exports use).
|
|
325
|
+
def truncated_json?(path)
|
|
326
|
+
return false if path.to_s.end_with?(".gz")
|
|
327
|
+
|
|
328
|
+
tail =
|
|
329
|
+
File.open(path, "rb") do |file|
|
|
330
|
+
file.seek(-[file.size, 256].min, IO::SEEK_END)
|
|
331
|
+
file.read
|
|
332
|
+
end
|
|
333
|
+
last = tail.to_s.rstrip[-1]
|
|
334
|
+
!["}", "]"].include?(last)
|
|
335
|
+
rescue SystemCallError, IOError
|
|
336
|
+
false
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def convert_file!(path, out, index: 1, count: 1)
|
|
340
|
+
log "\nProcessing: #{File.basename(path)}"
|
|
341
|
+
schema_path = find_schema(path)
|
|
342
|
+
streaming_passes = streaming_passes_for(path, schema_path)
|
|
343
|
+
file_started_at = Time.now
|
|
344
|
+
rows_before = @stats[:rows_processed]
|
|
345
|
+
child_rows_before = @stats[:child_rows_processed]
|
|
346
|
+
@current_file_progress = {
|
|
347
|
+
path: path.to_s,
|
|
348
|
+
name: File.basename(path),
|
|
349
|
+
index: index,
|
|
350
|
+
count: count,
|
|
351
|
+
size: IOSupport.estimated_input_bytes(path) * streaming_passes,
|
|
352
|
+
bytes_read: 0,
|
|
353
|
+
pass: 1,
|
|
354
|
+
passes: streaming_passes,
|
|
355
|
+
record_count: nil
|
|
356
|
+
}
|
|
357
|
+
report_progress(:file_start, force: true)
|
|
358
|
+
|
|
359
|
+
registry = Json::NameRegistry.new
|
|
360
|
+
records_path = records_path_for(path)
|
|
361
|
+
if schema_path
|
|
362
|
+
loaded =
|
|
363
|
+
Json::JsonSchemaLoader.new(
|
|
364
|
+
registry: registry,
|
|
365
|
+
max_depth: @max_depth,
|
|
366
|
+
graphql_unwrap: @graphql_unwrap,
|
|
367
|
+
raw_dates: @raw_dates
|
|
368
|
+
).load(schema_path, records_path: records_path)
|
|
369
|
+
@schema_used = true
|
|
370
|
+
tables =
|
|
371
|
+
loaded.plans.transform_values do |plan|
|
|
372
|
+
{ defs: plan.defs, child: plan.child }
|
|
373
|
+
end
|
|
374
|
+
root_table =
|
|
375
|
+
resolve_root_table({ "object_name" => loaded.root_name }, path)
|
|
376
|
+
end
|
|
377
|
+
shredder =
|
|
378
|
+
Json::Shredder.new(
|
|
379
|
+
registry: registry,
|
|
380
|
+
max_depth: @max_depth,
|
|
381
|
+
graphql_unwrap: @graphql_unwrap,
|
|
382
|
+
json_column_paths: @json_columns,
|
|
383
|
+
forced_json_paths: loaded&.forced_json
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
unless schema_path
|
|
387
|
+
inferrer = Json::SchemaInferrer.new(raw_dates: @raw_dates)
|
|
388
|
+
result =
|
|
389
|
+
stream_records(path) do |record, ordinal|
|
|
390
|
+
inferrer.observe(shredder.shred(record, ordinal))
|
|
391
|
+
end
|
|
392
|
+
# The emit pass re-shreds every record, so fallback/collision counts
|
|
393
|
+
# are taken from the inference pass only.
|
|
394
|
+
@stats[:json_fallback_columns] += shredder.json_fallbacks
|
|
395
|
+
@stats[:column_collisions] += registry.collisions
|
|
396
|
+
warn_total_records(path, result)
|
|
397
|
+
@current_file_progress[:record_count] = result.record_count
|
|
398
|
+
report_progress(:pass_complete, force: true)
|
|
399
|
+
tables =
|
|
400
|
+
inferrer.profiles.transform_values do |profile|
|
|
401
|
+
{ defs: profile.finalize, child: profile.child? }
|
|
402
|
+
end
|
|
403
|
+
root_table = resolve_root_table(result.envelope, path)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
definitions =
|
|
407
|
+
build_definitions(path, tables, root_table, strict: !schema_path.nil?)
|
|
408
|
+
definitions.each_value do |definition|
|
|
409
|
+
@emitter.write_create_table(
|
|
410
|
+
out,
|
|
411
|
+
definition[:name],
|
|
412
|
+
definition[:defs],
|
|
413
|
+
child: definition[:child]
|
|
414
|
+
)
|
|
415
|
+
@stats[:tables_processed] += 1
|
|
416
|
+
report_progress(:table, current_table: definition[:name])
|
|
417
|
+
end
|
|
418
|
+
collect_pii(loaded, definitions, schema_path) if loaded
|
|
419
|
+
|
|
420
|
+
unless @schema_only
|
|
421
|
+
@current_file_progress[:pass] = 2 unless schema_path
|
|
422
|
+
sid_counters = Hash.new(0)
|
|
423
|
+
buffers = Hash.new { |hash, rel| hash[rel] = [] }
|
|
424
|
+
emit_result =
|
|
425
|
+
stream_records(path) do |record, ordinal|
|
|
426
|
+
emit_row(
|
|
427
|
+
out,
|
|
428
|
+
shredder.shred(record, ordinal),
|
|
429
|
+
nil,
|
|
430
|
+
definitions,
|
|
431
|
+
sid_counters,
|
|
432
|
+
buffers
|
|
433
|
+
)
|
|
434
|
+
end
|
|
435
|
+
buffers.each { |rel, rows| flush_batch(out, definitions[rel], rows) }
|
|
436
|
+
warn_total_records(path, emit_result) if schema_path
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
@stats[:files_processed] += 1
|
|
440
|
+
ledger_entry = {
|
|
441
|
+
name: File.basename(path),
|
|
442
|
+
size: @current_file_progress[:size] / streaming_passes,
|
|
443
|
+
rows: @stats[:rows_processed] - rows_before,
|
|
444
|
+
child_rows: @stats[:child_rows_processed] - child_rows_before,
|
|
445
|
+
elapsed: Time.now - file_started_at
|
|
446
|
+
}
|
|
447
|
+
@ledger << ledger_entry
|
|
448
|
+
report_progress(
|
|
449
|
+
:file_complete,
|
|
450
|
+
file_rows: ledger_entry[:rows],
|
|
451
|
+
file_child_rows: ledger_entry[:child_rows],
|
|
452
|
+
file_elapsed: ledger_entry[:elapsed],
|
|
453
|
+
force: true
|
|
454
|
+
)
|
|
455
|
+
@current_file_progress = nil
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Streaming passes over one input: schema inference plus emit, or a
|
|
459
|
+
# single pass when a JSON Schema already describes it or only schema is
|
|
460
|
+
# requested.
|
|
461
|
+
def streaming_passes_for(path, schema_path = find_schema(path))
|
|
462
|
+
passes = schema_path ? 1 : 2
|
|
463
|
+
passes -= 1 if @schema_only
|
|
464
|
+
[passes, 1].max
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def find_schema(path)
|
|
468
|
+
return nil unless @schema_dir
|
|
469
|
+
|
|
470
|
+
base = File.basename(path.to_s).sub(/\.gz\z/, "").sub(/\.json\z/, "")
|
|
471
|
+
candidate = File.join(@schema_dir, "#{base}.schema.json")
|
|
472
|
+
return Pathname(candidate) if File.exist?(candidate)
|
|
473
|
+
|
|
474
|
+
emit_warning "No JSON Schema found for #{File.basename(path)} in #{@schema_dir}; inferring schema from the data"
|
|
475
|
+
nil
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def warn_total_records(path, result)
|
|
479
|
+
if result.truncated
|
|
480
|
+
@stats[:files_recovered] += 1
|
|
481
|
+
expected = result.envelope["total_records"]
|
|
482
|
+
expected_clause =
|
|
483
|
+
expected.is_a?(Integer) ? ", expected #{expected}" : ""
|
|
484
|
+
if result.skipped_lines.to_i.positive?
|
|
485
|
+
emit_warning "#{File.basename(path)} contains malformed NDJSON line(s) (#{result.parse_error}); " \
|
|
486
|
+
"skipped #{result.skipped_lines} line(s) and recovered #{result.record_count} complete record(s)#{expected_clause}. " \
|
|
487
|
+
"Re-export or repair the skipped line(s) for the full data."
|
|
488
|
+
return
|
|
489
|
+
end
|
|
490
|
+
emit_warning "#{File.basename(path)} is truncated (#{result.parse_error}); " \
|
|
491
|
+
"recovered #{result.record_count} complete record(s)#{expected_clause}. " \
|
|
492
|
+
"The remaining records are lost — re-export the file for the full data."
|
|
493
|
+
return
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
total_records = result.envelope["total_records"]
|
|
497
|
+
unless total_records.is_a?(Integer) &&
|
|
498
|
+
total_records != result.record_count
|
|
499
|
+
return
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
emit_warning "#{File.basename(path)}: total_records says #{total_records} but #{result.record_count} records were found"
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Accepts a String or a Diagnostic. +stats[:warnings]+ keeps the one-line
|
|
506
|
+
# strings; +stats[:diagnostics]+ and the :warning event carry the
|
|
507
|
+
# structured form for terminal blocks and the diagnostics report.
|
|
508
|
+
def emit_warning(warning)
|
|
509
|
+
diagnostic = Diagnostic.wrap(warning)
|
|
510
|
+
@stats[:warnings] << diagnostic.message
|
|
511
|
+
@stats[:diagnostics] << diagnostic
|
|
512
|
+
report_progress(
|
|
513
|
+
:warning,
|
|
514
|
+
message: diagnostic.message,
|
|
515
|
+
diagnostic: diagnostic,
|
|
516
|
+
force: true
|
|
517
|
+
)
|
|
518
|
+
log "[WARN] #{diagnostic.message}"
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Paths of the exporter's quality-signal files among the inputs, handed
|
|
522
|
+
# to the profile so diagnostics can point at them.
|
|
523
|
+
def quality_signal_paths(files)
|
|
524
|
+
%w[errors gaps].to_h do |table|
|
|
525
|
+
match = files.find { |file| json_basename(file) == table }
|
|
526
|
+
[table, match&.to_s]
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def write_diagnostics_report(source_label, output_path)
|
|
531
|
+
return if @diagnostics_report == false
|
|
532
|
+
|
|
533
|
+
report_path =
|
|
534
|
+
(
|
|
535
|
+
if @diagnostics_report.is_a?(String) ||
|
|
536
|
+
@diagnostics_report.is_a?(Pathname)
|
|
537
|
+
@diagnostics_report.to_s
|
|
538
|
+
else
|
|
539
|
+
DiagnosticsReport.default_path(output_path)
|
|
540
|
+
end
|
|
541
|
+
)
|
|
542
|
+
return if report_path.nil?
|
|
543
|
+
|
|
544
|
+
finished_at = Time.now
|
|
545
|
+
@stats[:diagnostics_report] = DiagnosticsReport.write(
|
|
546
|
+
report_path,
|
|
547
|
+
format: :json,
|
|
548
|
+
source: source_label,
|
|
549
|
+
output: output_path.to_s,
|
|
550
|
+
profile: @profile,
|
|
551
|
+
started_at: @conversion_started_at.utc.iso8601,
|
|
552
|
+
finished_at: finished_at.utc.iso8601,
|
|
553
|
+
elapsed: finished_at - @conversion_started_at,
|
|
554
|
+
stats: @stats,
|
|
555
|
+
input_bytes: @total_input_bytes,
|
|
556
|
+
diagnostics: @stats[:diagnostics],
|
|
557
|
+
ledger: @ledger,
|
|
558
|
+
manifest: @manifest,
|
|
559
|
+
source_paths: @source_paths
|
|
560
|
+
)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def collect_pii(loaded, definitions, schema_path)
|
|
564
|
+
loaded.pii.each do |entry|
|
|
565
|
+
definition = definitions[entry.table_rel]
|
|
566
|
+
next unless definition
|
|
567
|
+
|
|
568
|
+
@pii_meta << [
|
|
569
|
+
definition[:name],
|
|
570
|
+
entry.column,
|
|
571
|
+
entry.json_path,
|
|
572
|
+
1,
|
|
573
|
+
entry.note,
|
|
574
|
+
File.basename(schema_path)
|
|
575
|
+
]
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def build_definitions(path, tables, root_table, strict: false)
|
|
580
|
+
definitions = {}
|
|
581
|
+
tables.each do |rel, table|
|
|
582
|
+
name = final_table_name(root_table, rel)
|
|
583
|
+
if (previous = @emitted_tables[name])
|
|
584
|
+
raise UsageError,
|
|
585
|
+
"Table name '#{name}' is produced by both #{previous} and #{path}. Rename one file or use --include-files/--exclude-files."
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
unless process_table?(name, root: root_table)
|
|
589
|
+
@stats[:tables_skipped] += 1
|
|
590
|
+
next
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
@emitted_tables[name] = path.to_s
|
|
594
|
+
meta =
|
|
595
|
+
(
|
|
596
|
+
if table[:child]
|
|
597
|
+
Json::SqlEmitter::META_CHILD
|
|
598
|
+
else
|
|
599
|
+
Json::SqlEmitter::META_ROOT
|
|
600
|
+
end
|
|
601
|
+
)
|
|
602
|
+
column_names = table[:defs].map(&:name)
|
|
603
|
+
definitions[rel] = {
|
|
604
|
+
name: name,
|
|
605
|
+
defs: table[:defs],
|
|
606
|
+
child: table[:child],
|
|
607
|
+
columns: meta + column_names,
|
|
608
|
+
known_columns: strict ? column_names.to_set : nil,
|
|
609
|
+
context_columns: column_names.grep(/\Acontext_/).sort
|
|
610
|
+
}
|
|
611
|
+
@profile_tables[name] = meta + column_names if @profile
|
|
612
|
+
end
|
|
613
|
+
definitions
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def emit_row(out, row, parent_sid, definitions, sid_counters, buffers)
|
|
617
|
+
sid = (sid_counters[row.table_rel] += 1)
|
|
618
|
+
definition = definitions[row.table_rel]
|
|
619
|
+
if definition
|
|
620
|
+
if @profile_validator
|
|
621
|
+
@profile_validator.observe(
|
|
622
|
+
definition[:name],
|
|
623
|
+
row.columns,
|
|
624
|
+
sid,
|
|
625
|
+
parent_sid: definition[:child] ? parent_sid : nil,
|
|
626
|
+
context_columns: definition[:context_columns]
|
|
627
|
+
)
|
|
628
|
+
end
|
|
629
|
+
values =
|
|
630
|
+
if definition[:child]
|
|
631
|
+
[
|
|
632
|
+
sid.to_s,
|
|
633
|
+
parent_sid.to_s,
|
|
634
|
+
SqlText.escape_sql_string(row.parent_natural_id),
|
|
635
|
+
row.ordinal.to_s
|
|
636
|
+
]
|
|
637
|
+
else
|
|
638
|
+
[sid.to_s]
|
|
639
|
+
end
|
|
640
|
+
values.concat(
|
|
641
|
+
definition[:defs].map do |col|
|
|
642
|
+
@emitter.format_value(row.columns[col.name], col)
|
|
643
|
+
end
|
|
644
|
+
)
|
|
645
|
+
if (known = definition[:known_columns])
|
|
646
|
+
row.columns.each_key do |column|
|
|
647
|
+
@stats[:dropped_values] += 1 unless known.include?(column)
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
buffer = buffers[row.table_rel]
|
|
651
|
+
buffer << values
|
|
652
|
+
if definition[:child]
|
|
653
|
+
@stats[:child_rows_processed] += 1
|
|
654
|
+
else
|
|
655
|
+
@stats[:rows_processed] += 1
|
|
656
|
+
end
|
|
657
|
+
report_progress(:rows)
|
|
658
|
+
flush_batch(out, definition, buffer) if buffer.length >= @batch_size
|
|
659
|
+
end
|
|
660
|
+
row.children.each do |child|
|
|
661
|
+
emit_row(out, child, sid, definitions, sid_counters, buffers)
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def flush_batch(out, definition, rows)
|
|
666
|
+
return if definition.nil? || rows.empty?
|
|
667
|
+
|
|
668
|
+
@emitter.write_batch(out, definition[:name], definition[:columns], rows)
|
|
669
|
+
rows.clear
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def resolve_root_table(envelope, path)
|
|
673
|
+
base = @table_name
|
|
674
|
+
base ||= envelope["object_name"] if envelope["object_name"].is_a?(String)
|
|
675
|
+
base ||= "data" if @io_source
|
|
676
|
+
base ||= json_basename(path)
|
|
677
|
+
Json::NameRegistry.sanitize(base)
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def final_table_name(root_table, rel)
|
|
681
|
+
if rel.empty?
|
|
682
|
+
root_table
|
|
683
|
+
else
|
|
684
|
+
Json::NameRegistry.truncate("#{root_table}_#{rel}")
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def stream_records(path, &block)
|
|
689
|
+
open_input(path) do |io|
|
|
690
|
+
counting = Json::CountingIO.new(io) { |bytes| track_input_bytes(bytes) }
|
|
691
|
+
Json::RecordStreamer.new(
|
|
692
|
+
records_path: records_path_for(path)
|
|
693
|
+
).each_record(
|
|
694
|
+
counting,
|
|
695
|
+
recover: @recover_truncated,
|
|
696
|
+
ndjson: @ndjson,
|
|
697
|
+
&block
|
|
698
|
+
)
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# Returns the records path to use for +path+. Per-file config takes
|
|
703
|
+
# precedence over the global --records-path flag.
|
|
704
|
+
def records_path_for(path)
|
|
705
|
+
if @records_path_config
|
|
706
|
+
base = json_basename(path)
|
|
707
|
+
return @records_path_config[base] if @records_path_config.key?(base)
|
|
708
|
+
end
|
|
709
|
+
@records_path
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# Loads the YAML (or JSON) records-path config file and returns a Hash
|
|
713
|
+
# mapping filename base (without extension) to a dot-notation path string.
|
|
714
|
+
def load_records_path_config(config_path)
|
|
715
|
+
raw =
|
|
716
|
+
YAML.safe_load(
|
|
717
|
+
File.read(config_path),
|
|
718
|
+
permitted_classes: [],
|
|
719
|
+
permitted_symbols: [],
|
|
720
|
+
aliases: true
|
|
721
|
+
)
|
|
722
|
+
unless raw.is_a?(Hash)
|
|
723
|
+
raise UsageError,
|
|
724
|
+
"Records path config must be a YAML mapping of filename => path (got #{raw.class})"
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
raw.each_with_object({}) do |(key, value), config|
|
|
728
|
+
unless value.is_a?(String)
|
|
729
|
+
raise UsageError,
|
|
730
|
+
"Records path config values must be strings, got #{value.inspect}"
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
config[key.to_s] = validate_records_path(
|
|
734
|
+
value,
|
|
735
|
+
"records path config entry #{key.inspect}"
|
|
736
|
+
)
|
|
737
|
+
end
|
|
738
|
+
rescue Psych::Exception => e
|
|
739
|
+
raise UsageError,
|
|
740
|
+
"Could not parse records path config #{config_path}: #{e.message}"
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def json_basename(path)
|
|
744
|
+
File
|
|
745
|
+
.basename(path.to_s)
|
|
746
|
+
.sub(/\.gz\z/, "")
|
|
747
|
+
.sub(/\.(?:json|jsonl|ndjson)\z/, "")
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
def validate_records_path(path, label)
|
|
751
|
+
return nil if path.nil?
|
|
752
|
+
|
|
753
|
+
value = path.to_s.strip
|
|
754
|
+
if value.empty? || value.start_with?(".") || value.end_with?(".") ||
|
|
755
|
+
value.split(".", -1).any?(&:empty?)
|
|
756
|
+
raise UsageError,
|
|
757
|
+
"Invalid #{label}: #{path.inspect}. Use dot-notation with non-empty path segments."
|
|
758
|
+
end
|
|
759
|
+
value
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def normalize_profile(profile)
|
|
763
|
+
return nil if profile.nil?
|
|
764
|
+
|
|
765
|
+
normalized = profile.to_s.downcase.tr("-", "_").to_sym
|
|
766
|
+
return normalized if Json::Profiles::REGISTRY.key?(normalized)
|
|
767
|
+
|
|
768
|
+
expected =
|
|
769
|
+
Json::Profiles::REGISTRY.keys.map { |name| name.to_s.tr("_", "-") }
|
|
770
|
+
raise UsageError,
|
|
771
|
+
"Unknown JSON preparation profile #{profile.inspect}; expected #{expected.join(", ")}"
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def profile_class
|
|
775
|
+
Json::Profiles::REGISTRY.fetch(@profile)
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def write_profile(out)
|
|
779
|
+
@profile_validator.validate!
|
|
780
|
+
warnings =
|
|
781
|
+
if @profile_validator.respond_to?(:diagnostics)
|
|
782
|
+
@profile_validator.diagnostics
|
|
783
|
+
else
|
|
784
|
+
@profile_validator.warnings
|
|
785
|
+
end
|
|
786
|
+
warnings.each { |warning| emit_warning(warning) }
|
|
787
|
+
profile_class.new(@profile_tables).write(out)
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def write_profile_preamble(out)
|
|
791
|
+
profile_class.write_preamble(out)
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def validate_profile_files!(files)
|
|
795
|
+
available = files.map { |file| json_basename(file) }
|
|
796
|
+
missing = profile_class::REQUIRED_SCHEMA.keys - available
|
|
797
|
+
return if missing.empty?
|
|
798
|
+
|
|
799
|
+
raise UsageError,
|
|
800
|
+
"Khoros API profile required collections are missing or filtered: #{missing.join(", ")}"
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
def track_input_bytes(bytes)
|
|
804
|
+
return unless @current_file_progress
|
|
805
|
+
|
|
806
|
+
@current_file_progress[:bytes_read] += bytes
|
|
807
|
+
@stats[:bytes_read] += bytes
|
|
808
|
+
report_progress(:bytes)
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
def report_progress(event, extra = {})
|
|
812
|
+
return unless @progress_callback || @on_progress
|
|
813
|
+
|
|
814
|
+
force = extra.delete(:force)
|
|
815
|
+
now = Time.now
|
|
816
|
+
if !force && @last_progress_at &&
|
|
817
|
+
now - @last_progress_at < @progress_interval
|
|
818
|
+
return
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
@last_progress_at = now
|
|
822
|
+
payload = {
|
|
823
|
+
event: event,
|
|
824
|
+
elapsed: Time.now - @conversion_started_at,
|
|
825
|
+
files_processed: @stats[:files_processed],
|
|
826
|
+
tables_processed: @stats[:tables_processed],
|
|
827
|
+
rows_processed: @stats[:rows_processed],
|
|
828
|
+
child_rows_processed: @stats[:child_rows_processed],
|
|
829
|
+
work_bytes_read: @stats[:bytes_read],
|
|
830
|
+
total_work_bytes: @total_work_bytes,
|
|
831
|
+
warnings_count: @stats[:warnings].length,
|
|
832
|
+
current_file: @current_file_progress&.dup
|
|
833
|
+
}.merge(extra)
|
|
834
|
+
@progress_callback&.call(payload)
|
|
835
|
+
@on_progress&.call(
|
|
836
|
+
ProgressEvent.new(event, payload.reject { |key, _| key == :event })
|
|
837
|
+
)
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
def temporary_output_path(path)
|
|
841
|
+
Pathname("#{path}.tmp")
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
def open_input(path)
|
|
845
|
+
if path.to_s.end_with?(".gz")
|
|
846
|
+
Zlib::GzipReader.open(path.to_s) { |gz| yield gz }
|
|
847
|
+
else
|
|
848
|
+
File.open(path, "rb") { |file| yield file }
|
|
849
|
+
end
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
def open_output(path, gzip: nil)
|
|
853
|
+
gzip = path.to_s.end_with?(".gz") if gzip.nil?
|
|
854
|
+
if gzip
|
|
855
|
+
Zlib::GzipWriter.open(path.to_s) { |gz| yield gz }
|
|
856
|
+
else
|
|
857
|
+
File.open(path, "w:utf-8") { |file| yield file }
|
|
858
|
+
end
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
def reset_stats
|
|
862
|
+
@stats = {
|
|
863
|
+
tables_processed: 0,
|
|
864
|
+
rows_processed: 0,
|
|
865
|
+
child_rows_processed: 0,
|
|
866
|
+
bytes_read: 0,
|
|
867
|
+
bytes_written: 0,
|
|
868
|
+
files_processed: 0,
|
|
869
|
+
files_skipped: 0,
|
|
870
|
+
tables_skipped: 0,
|
|
871
|
+
column_collisions: 0,
|
|
872
|
+
json_fallback_columns: 0,
|
|
873
|
+
dropped_values: 0,
|
|
874
|
+
files_recovered: 0,
|
|
875
|
+
warnings: [],
|
|
876
|
+
diagnostics: [],
|
|
877
|
+
diagnostics_report: nil
|
|
878
|
+
}
|
|
879
|
+
@ledger = []
|
|
880
|
+
@manifest = nil
|
|
881
|
+
@source_paths = {}
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
def process_table?(table, root:)
|
|
885
|
+
if @exclude_tables.include?(root) || @exclude_tables.include?(table)
|
|
886
|
+
return false
|
|
887
|
+
end
|
|
888
|
+
if @include_tables
|
|
889
|
+
return @include_tables.include?(table) || @include_tables.include?(root)
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
true
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def process_file?(file)
|
|
896
|
+
file_name = file.basename.to_s
|
|
897
|
+
base = file_name.sub(/\.gz\z/, "").sub(/\.(?:json|jsonl|ndjson)\z/, "")
|
|
898
|
+
if @include_files && !@include_files.include?(file_name) &&
|
|
899
|
+
!@include_files.include?(base)
|
|
900
|
+
return false
|
|
901
|
+
end
|
|
902
|
+
if @exclude_files.include?(file_name) || @exclude_files.include?(base)
|
|
903
|
+
return false
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
true
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def log(message)
|
|
910
|
+
@diagnostic_io.puts(message) if @verbose && @diagnostic_io
|
|
911
|
+
end
|
|
912
|
+
end
|
|
913
|
+
end
|