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,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ require_relative "format"
7
+ require_relative "io_support"
8
+ require_relative "version"
9
+
10
+ module StructuredDataToSql
11
+ # Writes the Markdown diagnostics report that sits beside a dump: what the
12
+ # run processed, every diagnostic with its complete lists and suggested
13
+ # action, the exporter runs that produced the source (when a manifest was
14
+ # found), and the per-file ledger. Written atomically and always — a clean
15
+ # run replaces a stale report with a "no warnings" one. Never includes
16
+ # member values, credentials, or per-row error payloads.
17
+ module DiagnosticsReport
18
+ module_function
19
+
20
+ def default_path(output)
21
+ return nil unless output.is_a?(String) || output.is_a?(Pathname)
22
+ return nil if output.to_s == "-"
23
+
24
+ "#{output}.diagnostics.md"
25
+ end
26
+
27
+ # +context+ keys: format, source, output, profile, started_at,
28
+ # finished_at, elapsed, stats (Hash), diagnostics (Array<Diagnostic>),
29
+ # ledger (Array<Hash name,size,rows,child_rows,elapsed>), manifest
30
+ # (Json::ExporterManifest or nil), source_paths (Hash table => path).
31
+ def write(path, context)
32
+ pathname = Pathname(path)
33
+ temporary = IOSupport.unique_adjacent_path(pathname)
34
+ begin
35
+ File.write(temporary, render(context))
36
+ File.rename(temporary, pathname)
37
+ ensure
38
+ FileUtils.rm_f(temporary) if temporary.exist?
39
+ end
40
+ pathname.to_s
41
+ rescue SystemCallError, IOError => e
42
+ raise OutputError,
43
+ "Could not write diagnostics report #{path}: #{e.message}"
44
+ end
45
+
46
+ def render(context)
47
+ stats = context[:stats] || {}
48
+ diagnostics = Array(context[:diagnostics])
49
+ lines = []
50
+ lines << "# Conversion diagnostics"
51
+ lines << ""
52
+ lines << "| | |"
53
+ lines << "|---|---|"
54
+ lines << "| Tool | structured_data_to_sql #{VERSION} (#{context[:format]}) |"
55
+ lines << "| Source | `#{context[:source]}` |"
56
+ lines << "| Output | `#{context[:output]}` |"
57
+ lines << "| Profile | #{context[:profile] || "none"} |"
58
+ lines << "| Started | #{context[:started_at]} |" if context[:started_at]
59
+ if context[:finished_at]
60
+ lines << "| Finished | #{context[:finished_at]} |"
61
+ end
62
+ if context[:elapsed]
63
+ lines << "| Elapsed | #{Format.format_duration(context[:elapsed])} |"
64
+ end
65
+ lines << ""
66
+ lines.concat(
67
+ render_summary(stats, diagnostics.length, context[:input_bytes])
68
+ )
69
+ lines.concat(render_diagnostics(diagnostics))
70
+ lines.concat(render_manifest(context[:manifest], context[:source_paths]))
71
+ lines.concat(render_ledger(context[:ledger]))
72
+ "#{lines.join("\n")}\n"
73
+ end
74
+
75
+ def render_summary(stats, warning_count, input_bytes = nil)
76
+ lines = ["## Run summary", ""]
77
+ lines << "- Files processed: #{Format.format_count(stats[:files_processed])}" \
78
+ "#{stats[:files_skipped].to_i.positive? ? " (#{Format.format_count(stats[:files_skipped])} skipped)" : ""}"
79
+ lines << "- Rows: #{Format.format_count(stats[:rows_processed])}" \
80
+ "#{stats[:child_rows_processed].to_i.positive? ? " (+#{Format.format_count(stats[:child_rows_processed])} child rows)" : ""}"
81
+ lines << "- Tables: #{Format.format_count(stats[:tables_processed])}" \
82
+ "#{stats[:tables_skipped].to_i.positive? ? " (#{Format.format_count(stats[:tables_skipped])} skipped)" : ""}"
83
+ if input_bytes
84
+ lines << "- Input: #{Format.format_size(input_bytes)} · Read: #{Format.format_size(stats[:bytes_read])} (all passes) · Written: #{Format.format_size(stats[:bytes_written])}"
85
+ elsif stats[:bytes_read]
86
+ lines << "- Read: #{Format.format_size(stats[:bytes_read])} · Written: #{Format.format_size(stats[:bytes_written])}"
87
+ end
88
+ lines << "- Warnings: #{warning_count}"
89
+ lines << ""
90
+ lines
91
+ end
92
+
93
+ def render_diagnostics(diagnostics)
94
+ lines = ["## Diagnostics", ""]
95
+ if diagnostics.empty?
96
+ lines << "No warnings were raised by this conversion."
97
+ lines << ""
98
+ return lines
99
+ end
100
+
101
+ diagnostics.each_with_index do |diagnostic, index|
102
+ heading = "### #{index + 1}. #{diagnostic.title}"
103
+ heading +=
104
+ " (#{Format.format_count(diagnostic.count)})" if diagnostic.count
105
+ lines << heading
106
+ lines << ""
107
+ lines << "`#{diagnostic.code}`"
108
+ lines << ""
109
+ if diagnostic.summary
110
+ lines << diagnostic.summary
111
+ lines << ""
112
+ end
113
+ diagnostic.details.each { |detail| lines << "- #{detail}" }
114
+ lines << "" if diagnostic.details.any?
115
+ if diagnostic.items.any?
116
+ lines << "**#{diagnostic.items_label || "Items"}** (#{Format.format_count(diagnostic.items.length)}):"
117
+ lines << ""
118
+ diagnostic.items.each { |item| lines << "- `#{item}`" }
119
+ lines << ""
120
+ end
121
+ if diagnostic.action
122
+ lines << "**Action:** #{diagnostic.action}"
123
+ lines << ""
124
+ end
125
+ if diagnostic.sources.any?
126
+ lines << "**Sources:** #{diagnostic.sources.map { |source| "`#{source}`" }.join(", ")}"
127
+ lines << ""
128
+ end
129
+ lines << "<details><summary>Warning line</summary>"
130
+ lines << ""
131
+ lines << "```"
132
+ lines << diagnostic.message
133
+ lines << "```"
134
+ lines << ""
135
+ lines << "</details>"
136
+ lines << ""
137
+ end
138
+ lines
139
+ end
140
+
141
+ def render_manifest(manifest, source_paths)
142
+ return [] if manifest.nil?
143
+
144
+ lines = ["## Exporter runs", ""]
145
+ lines << "Manifest: `#{manifest.path}`"
146
+ lines << ""
147
+ if manifest.problem
148
+ lines << "Not usable for run scoping: #{manifest.problem}."
149
+ lines << ""
150
+ return lines
151
+ end
152
+
153
+ lines << "Quality signals are scoped to the latest run whose manifest entry is `completed`, " \
154
+ "by attributing the last _N_ rows of the append-only errors/gaps files to it (_N_ = that run's declared count). " \
155
+ "Rows beyond the sum of declared counts belong to runs that never finalized their entry."
156
+ lines << ""
157
+ lines << "| Run | Status | Started | Finished | Mode | Credential | Emails | Errors | Gaps |"
158
+ lines << "|---:|---|---|---|---|---|---|---:|---:|"
159
+ manifest.runs.each do |run|
160
+ lines << "| #{run.index} | #{run.status} | #{run.started_at} | #{run.finished_at} | #{run.mode} | " \
161
+ "#{run.credential} | #{run.user_emails} | #{Format.format_count(run.errors)} | #{Format.format_count(run.gaps)} |"
162
+ end
163
+ lines << ""
164
+ Array(source_paths).each do |table, path|
165
+ lines << "- Raw `#{table}` rows: `#{path}`" if path
166
+ end
167
+ lines << "" if source_paths&.any?
168
+ lines
169
+ end
170
+
171
+ def render_ledger(ledger)
172
+ ledger = Array(ledger)
173
+ return [] if ledger.empty?
174
+
175
+ lines = ["## Files", ""]
176
+ lines << "| File | Size | Rows | Child rows | Time |"
177
+ lines << "|---|---:|---:|---:|---:|"
178
+ ledger.each do |entry|
179
+ lines << "| #{entry[:name]} | #{Format.format_size(entry[:size])} | " \
180
+ "#{Format.format_count(entry[:rows])} | #{Format.format_count(entry[:child_rows])} | " \
181
+ "#{Format.format_duration(entry[:elapsed])} |"
182
+ end
183
+ lines << ""
184
+ lines
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredDataToSql
4
+ class Error < StandardError
5
+ end
6
+
7
+ class ConfigurationError < Error
8
+ end
9
+ class InputError < Error
10
+ end
11
+ class ParseError < Error
12
+ end
13
+ class OutputError < Error
14
+ end
15
+
16
+ # Kept as a compatibility name for the pre-public extraction.
17
+ UsageError = ConfigurationError
18
+
19
+ module Json
20
+ class Error < StructuredDataToSql::ConfigurationError
21
+ end
22
+ class ConfigurationError < Error
23
+ end
24
+ class InputError < Error
25
+ end
26
+ class ParseError < Error
27
+ end
28
+ class OutputError < Error
29
+ end
30
+ end
31
+
32
+ module MysqlDumpXml
33
+ class Error < StructuredDataToSql::ConfigurationError
34
+ end
35
+ class ConfigurationError < Error
36
+ end
37
+ class InputError < Error
38
+ end
39
+ class ParseError < Error
40
+ end
41
+ class OutputError < Error
42
+ end
43
+ end
44
+
45
+ # The public XML namespace. The implementation name remains precise because
46
+ # this format accepts mysqldump XML rather than arbitrary XML documents.
47
+ Xml = MysqlDumpXml
48
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredDataToSql
4
+ module Format
5
+ module_function
6
+
7
+ def format_size(size)
8
+ size = size.to_i
9
+ if size >= 1024 * 1024 * 1024
10
+ return format("%.1f GB", size / 1024.0 / 1024.0 / 1024.0)
11
+ end
12
+ return format("%.1f MB", size / 1024.0 / 1024.0) if size >= 1024 * 1024
13
+ return format("%.1f KB", size / 1024.0) if size >= 1024
14
+
15
+ "#{size} B"
16
+ end
17
+
18
+ def format_count(count)
19
+ count.to_i.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
20
+ end
21
+
22
+ def format_duration(seconds)
23
+ seconds = seconds.to_f
24
+ return format("%.1fs", seconds) if seconds < 60
25
+
26
+ total = seconds.round
27
+ hours, remainder = total.divmod(3600)
28
+ minutes, secs = remainder.divmod(60)
29
+ return format("%dh%02dm", hours, minutes) if hours.positive?
30
+
31
+ format("%dm%02ds", minutes, secs)
32
+ end
33
+
34
+ def format_rate(bytes, seconds)
35
+ seconds = seconds.to_f
36
+ return nil if seconds <= 0
37
+
38
+ "#{format_size(bytes.to_f / seconds)}/s"
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "securerandom"
5
+ require "tempfile"
6
+ require "zlib"
7
+ require "fileutils"
8
+
9
+ module StructuredDataToSql
10
+ module IOSupport
11
+ class CountingWriter
12
+ attr_reader :bytes_written
13
+
14
+ def initialize(io)
15
+ @io = io
16
+ @bytes_written = 0
17
+ end
18
+
19
+ def write(data)
20
+ written = @io.write(data)
21
+ @bytes_written += written
22
+ written
23
+ end
24
+
25
+ def method_missing(name, *args, &block)
26
+ @io.public_send(name, *args, &block)
27
+ end
28
+
29
+ def respond_to_missing?(name, include_private = false)
30
+ @io.respond_to?(name, include_private) || super
31
+ end
32
+ end
33
+
34
+ module_function
35
+
36
+ def readable_io?(object)
37
+ object.respond_to?(:read) && !object.is_a?(String) &&
38
+ !object.is_a?(Pathname)
39
+ end
40
+
41
+ def writable_io?(object)
42
+ object.respond_to?(:write) && !object.is_a?(String) &&
43
+ !object.is_a?(Pathname)
44
+ end
45
+
46
+ def discover(source, patterns:, stream_extension:, input_gzip: false)
47
+ temporary_files = []
48
+ source_items = readable_io?(source) ? [source] : Array(source)
49
+ candidates =
50
+ source_items.flat_map do |item|
51
+ if readable_io?(item)
52
+ suffix = "#{stream_extension}#{input_gzip ? ".gz" : ""}"
53
+ tempfile = Tempfile.new(["structured-data-to-sql-input-", suffix])
54
+ tempfile.binmode
55
+ IO.copy_stream(item, tempfile)
56
+ tempfile.close
57
+ temporary_files << tempfile
58
+ Pathname(tempfile.path)
59
+ else
60
+ path = Pathname(item)
61
+ raise InputError, "Input not found: #{path}" unless path.exist?
62
+
63
+ if path.directory?
64
+ patterns.flat_map { |pattern| path.glob(pattern).to_a }
65
+ else
66
+ path
67
+ end
68
+ end
69
+ end
70
+ [candidates.flatten.uniq.sort_by(&:to_s), temporary_files]
71
+ rescue SystemCallError, IOError => e
72
+ temporary_files&.each(&:unlink)
73
+ raise InputError, e.message
74
+ end
75
+
76
+ # Bytes the streaming reader will hand out for +path+: the file size for
77
+ # plain files, the gzip ISIZE trailer (uncompressed length modulo 2**32,
78
+ # lifted until it is at least the compressed size) for .gz files. One
79
+ # four-byte read per file, no decompression, never raises.
80
+ def estimated_input_bytes(path)
81
+ pathname = Pathname(path)
82
+ size = pathname.size
83
+ return size unless pathname.extname == ".gz" && size >= 18
84
+
85
+ trailer = File.binread(pathname.to_s, 4, size - 4)
86
+ estimate = trailer.unpack1("V")
87
+ estimate += 2**32 while estimate < size
88
+ estimate
89
+ rescue StandardError
90
+ begin
91
+ Pathname(path).size
92
+ rescue StandardError
93
+ 0
94
+ end
95
+ end
96
+
97
+ def unique_adjacent_path(path)
98
+ pathname = Pathname(path)
99
+ pathname.dirname.join(
100
+ ".#{pathname.basename}.tmp-#{Process.pid}-#{SecureRandom.hex(8)}"
101
+ )
102
+ end
103
+
104
+ def validate_output_target!(target)
105
+ return if writable_io?(target)
106
+
107
+ path = Pathname(target)
108
+ return unless path.directory?
109
+
110
+ raise OutputError,
111
+ "Output path is a directory; provide a file path: #{path}"
112
+ end
113
+
114
+ def with_output(target, gzip:, atomic: true)
115
+ validate_output_target!(target)
116
+ if writable_io?(target)
117
+ counting_target = CountingWriter.new(target)
118
+ if gzip
119
+ writer = Zlib::GzipWriter.new(counting_target)
120
+ begin
121
+ yield writer
122
+ ensure
123
+ writer.finish
124
+ end
125
+ else
126
+ yield counting_target
127
+ end
128
+ return counting_target.bytes_written
129
+ end
130
+
131
+ path = Pathname(target)
132
+ temporary_path = atomic ? unique_adjacent_path(path) : path
133
+ begin
134
+ if gzip
135
+ Zlib::GzipWriter.open(temporary_path.to_s) do |gzip_writer|
136
+ yield gzip_writer
137
+ end
138
+ else
139
+ File.open(temporary_path, "w:utf-8") do |file_writer|
140
+ yield file_writer
141
+ end
142
+ end
143
+ File.rename(temporary_path, path) if atomic
144
+ File.size(path)
145
+ rescue SystemCallError, IOError, Zlib::Error => e
146
+ raise OutputError, e.message
147
+ ensure
148
+ if atomic && temporary_path && temporary_path.exist?
149
+ FileUtils.rm_f(temporary_path)
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ module StructuredDataToSql
7
+ module Json
8
+ # Reads the Khoros exporter's manifest.json (kept beside the ndjson/
9
+ # directory) just far enough to scope quality signals to the latest run.
10
+ # Everything is best-effort: a missing or unparsable manifest yields an
11
+ # instance with +problem+ set and no runs; nothing here ever raises.
12
+ class ExporterManifest
13
+ Run =
14
+ Struct.new(
15
+ :index,
16
+ :status,
17
+ :started_at,
18
+ :finished_at,
19
+ :mode,
20
+ :credential,
21
+ :user_emails,
22
+ :errors,
23
+ :gaps,
24
+ keyword_init: true
25
+ ) do
26
+ def completed?
27
+ status == "completed"
28
+ end
29
+ end
30
+
31
+ FILENAME = "manifest.json"
32
+
33
+ attr_reader :path, :runs, :problem
34
+
35
+ # Looks in the source directory itself and in its parent, so both
36
+ # `export/ndjson` and `export/` work as the conversion source.
37
+ def self.locate(source)
38
+ source = source.first if source.is_a?(Array)
39
+ return nil unless source.is_a?(String) || source.is_a?(Pathname)
40
+
41
+ base = Pathname(source)
42
+ base = base.dirname if base.file?
43
+ return nil unless base.directory?
44
+
45
+ [base, base.parent].each do |directory|
46
+ candidate = directory.join(FILENAME)
47
+ return candidate if candidate.file?
48
+ end
49
+ nil
50
+ rescue SystemCallError
51
+ nil
52
+ end
53
+
54
+ def self.load(path)
55
+ return nil if path.nil?
56
+
57
+ new(path)
58
+ end
59
+
60
+ def initialize(path)
61
+ @path = Pathname(path)
62
+ @runs = []
63
+ @problem = nil
64
+ parse
65
+ end
66
+
67
+ def latest_completed
68
+ @runs.reverse.find(&:completed?)
69
+ end
70
+
71
+ def errors_total
72
+ @runs.sum(&:errors)
73
+ end
74
+
75
+ def gaps_total
76
+ @runs.sum(&:gaps)
77
+ end
78
+
79
+ private
80
+
81
+ def parse
82
+ document = JSON.parse(File.read(@path.to_s))
83
+ runs = document.is_a?(Hash) ? document["runs"] : nil
84
+ unless runs.is_a?(Array)
85
+ @problem = "manifest has no runs array"
86
+ return
87
+ end
88
+
89
+ @runs =
90
+ runs.each_with_index.map do |run, index|
91
+ run = {} unless run.is_a?(Hash)
92
+ capabilities =
93
+ run["capabilities"].is_a?(Hash) ? run["capabilities"] : {}
94
+ options = run["options"].is_a?(Hash) ? run["options"] : {}
95
+ counts = run["counts"].is_a?(Hash) ? run["counts"] : {}
96
+ Run.new(
97
+ index: index,
98
+ status: run["status"].to_s,
99
+ started_at: run["started_at"],
100
+ finished_at: run["finished_at"],
101
+ mode: options["mode"],
102
+ credential: capabilities["credential"],
103
+ user_emails: capabilities["user_emails"],
104
+ errors: integer_of(run["errors"]),
105
+ gaps: integer_of(counts["gaps"])
106
+ )
107
+ end
108
+ rescue JSON::ParserError, SystemCallError, IOError, EncodingError => e
109
+ @runs = []
110
+ @problem = "manifest unreadable: #{e.message[0, 120]}"
111
+ end
112
+
113
+ def integer_of(value)
114
+ case value
115
+ when Integer
116
+ value
117
+ when Array
118
+ value.length
119
+ when Hash
120
+ value.values.sum { |item| integer_of(item) }
121
+ else
122
+ 0
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end