structured_data_to_sql 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +9 -0
  3. data/bin/structured-data-to-sql +6 -0
  4. data/lib/structured_data_to_sql/cli.rb +405 -0
  5. data/lib/structured_data_to_sql/conversion_result.rb +94 -0
  6. data/lib/structured_data_to_sql/diagnostic.rb +82 -0
  7. data/lib/structured_data_to_sql/diagnostics_report.rb +187 -0
  8. data/lib/structured_data_to_sql/errors.rb +48 -0
  9. data/lib/structured_data_to_sql/format.rb +41 -0
  10. data/lib/structured_data_to_sql/io_support.rb +154 -0
  11. data/lib/structured_data_to_sql/json/exporter_manifest.rb +127 -0
  12. data/lib/structured_data_to_sql/json/json_schema_loader.rb +310 -0
  13. data/lib/structured_data_to_sql/json/profiles/khoros_api_export.rb +1960 -0
  14. data/lib/structured_data_to_sql/json/profiles.rb +14 -0
  15. data/lib/structured_data_to_sql/json/record_streamer.rb +477 -0
  16. data/lib/structured_data_to_sql/json/schema_inferrer.rb +150 -0
  17. data/lib/structured_data_to_sql/json/shredder.rb +241 -0
  18. data/lib/structured_data_to_sql/json/sql_emitter.rb +198 -0
  19. data/lib/structured_data_to_sql/json_converter.rb +913 -0
  20. data/lib/structured_data_to_sql/mysql_dump_xml/invalid_character_report.rb +82 -0
  21. data/lib/structured_data_to_sql/mysql_dump_xml/sanitizer.rb +152 -0
  22. data/lib/structured_data_to_sql/mysql_dump_xml/sax_parser.rb +111 -0
  23. data/lib/structured_data_to_sql/mysql_dump_xml/sql_emitter.rb +104 -0
  24. data/lib/structured_data_to_sql/mysql_dump_xml/table_data_filter.rb +343 -0
  25. data/lib/structured_data_to_sql/mysql_dump_xml/table_discovery.rb +651 -0
  26. data/lib/structured_data_to_sql/mysql_dump_xml/table_structure.rb +98 -0
  27. data/lib/structured_data_to_sql/mysql_dump_xml_converter.rb +4 -0
  28. data/lib/structured_data_to_sql/options.rb +89 -0
  29. data/lib/structured_data_to_sql/progress_reporter.rb +348 -0
  30. data/lib/structured_data_to_sql/sql_text.rb +29 -0
  31. data/lib/structured_data_to_sql/version.rb +5 -0
  32. data/lib/structured_data_to_sql/xml_converter.rb +651 -0
  33. data/lib/structured_data_to_sql/xml_dump_converter.rb +4 -0
  34. data/lib/structured_data_to_sql.rb +54 -0
  35. metadata +120 -0
@@ -0,0 +1,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