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,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
|