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,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sql_text"
4
+
5
+ module StructuredDataToSql
6
+ module MysqlDumpXml
7
+ class TableStructure
8
+ attr_reader :name, :columns, :primary_keys, :indexes, :unique_keys
9
+
10
+ def initialize(name)
11
+ @name = name
12
+ @columns = []
13
+ @primary_keys = []
14
+ @indexes = {}
15
+ @unique_keys = {}
16
+ end
17
+
18
+ def add_column(attrs)
19
+ col = {
20
+ name: attrs["Field"].to_s,
21
+ type: attrs["Type"] || "text",
22
+ null: attrs["Null"] == "YES",
23
+ key: attrs["Key"].to_s,
24
+ default: attrs["Default"],
25
+ extra: attrs["Extra"].to_s
26
+ }
27
+ @columns << col
28
+ case col[:key]
29
+ when "PRI"
30
+ @primary_keys << col[:name]
31
+ when "UNI"
32
+ @unique_keys["uk_#{col[:name]}"] = [col[:name]]
33
+ when "MUL"
34
+ @indexes["idx_#{col[:name]}"] = [col[:name]]
35
+ end
36
+ end
37
+
38
+ def column_names
39
+ @columns.map { |col| col[:name] }
40
+ end
41
+
42
+ def to_create_table_sql
43
+ lines =
44
+ @columns.map do |col|
45
+ definition =
46
+ " #{SqlText.escape_identifier(col[:name])} #{col[:type]}"
47
+ definition += " NOT NULL" unless col[:null]
48
+ if col[:default] && col[:default] != "null"
49
+ default = col[:default]
50
+ definition +=
51
+ if default.start_with?("CURRENT_") || default == "NULL" ||
52
+ numeric_type?(col[:type])
53
+ " DEFAULT #{default}"
54
+ else
55
+ " DEFAULT #{SqlText.escape_sql_string(default)}"
56
+ end
57
+ elsif col[:null] && col[:default] == "null"
58
+ definition += " DEFAULT NULL"
59
+ end
60
+ if col[:extra].downcase.include?("auto_increment")
61
+ definition += " AUTO_INCREMENT"
62
+ elsif col[:extra].downcase.include?(
63
+ "on update current_timestamp"
64
+ ) || col[:extra].include?("DEFAULT_GENERATED")
65
+ definition +=
66
+ " ON UPDATE CURRENT_TIMESTAMP" unless definition.upcase.include?(
67
+ "ON UPDATE CURRENT_TIMESTAMP"
68
+ )
69
+ end
70
+ definition
71
+ end
72
+
73
+ if @primary_keys.any?
74
+ lines << " PRIMARY KEY (#{@primary_keys.map { |key| SqlText.escape_identifier(key) }.join(", ")})"
75
+ end
76
+ @unique_keys.each do |name, cols|
77
+ lines << " UNIQUE KEY #{SqlText.escape_identifier(name)} (#{cols.map { |col| SqlText.escape_identifier(col) }.join(", ")})"
78
+ end
79
+ @indexes.each do |name, cols|
80
+ lines << " KEY #{SqlText.escape_identifier(name)} (#{cols.map { |col| SqlText.escape_identifier(col) }.join(", ")})"
81
+ end
82
+
83
+ "DROP TABLE IF EXISTS #{SqlText.escape_identifier(@name)};\n" \
84
+ "CREATE TABLE #{SqlText.escape_identifier(@name)} (\n" \
85
+ "#{lines.join(",\n")}\n" \
86
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;\n"
87
+ end
88
+
89
+ private
90
+
91
+ def numeric_type?(type)
92
+ type.match?(
93
+ /\A(?:int|bigint|tinyint|smallint|mediumint|float|double|decimal|numeric)/i
94
+ )
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Compatibility require for the original format-specific filename.
4
+ require_relative "xml_converter"
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredDataToSql
4
+ class Options
5
+ attr_reader :values
6
+
7
+ def initialize(**values)
8
+ @values = defaults.merge(values).freeze
9
+ validate!
10
+ freeze
11
+ end
12
+
13
+ def to_h
14
+ @values.dup
15
+ end
16
+
17
+ private
18
+
19
+ def defaults
20
+ { batch_size: 1000, schema_only: false, verbose: false }
21
+ end
22
+
23
+ def validate!
24
+ unless @values[:batch_size].to_i.positive?
25
+ raise ConfigurationError, "Batch size must be greater than 0"
26
+ end
27
+ end
28
+ end
29
+
30
+ class JsonOptions < Options
31
+ private
32
+
33
+ def defaults
34
+ super.merge(
35
+ max_depth: 5,
36
+ ndjson: :auto,
37
+ graphql_unwrap: true,
38
+ meta_table: true,
39
+ recover_truncated: false,
40
+ input_gzip: false,
41
+ output_gzip: nil
42
+ )
43
+ end
44
+
45
+ def validate!
46
+ unless @values[:batch_size].to_i.positive?
47
+ raise ConfigurationError, "JSON batch size must be greater than 0"
48
+ end
49
+ unless @values[:max_depth].to_i.positive?
50
+ raise ConfigurationError, "JSON max depth must be greater than 0"
51
+ end
52
+ unless %i[auto json ndjson].include?(normalized_mode)
53
+ raise ConfigurationError,
54
+ "JSON input mode must be auto, json, or ndjson"
55
+ end
56
+ end
57
+
58
+ def normalized_mode
59
+ return :ndjson if @values[:ndjson] == true
60
+ return :json if @values[:ndjson] == false
61
+
62
+ @values[:ndjson].to_sym
63
+ end
64
+ end
65
+
66
+ class XmlOptions < Options
67
+ private
68
+
69
+ def defaults
70
+ super.merge(
71
+ scrub_invalid_xml_chars: true,
72
+ input_gzip: false,
73
+ output_gzip: nil
74
+ )
75
+ end
76
+
77
+ def validate!
78
+ unless @values[:batch_size].to_i.positive?
79
+ raise ConfigurationError, "XML batch size must be greater than 0"
80
+ end
81
+ end
82
+ end
83
+
84
+ Json::Options = JsonOptions
85
+ Xml::Options = XmlOptions
86
+
87
+ # Compatibility alias for the original format-specific API.
88
+ MysqlDumpXmlOptions = XmlOptions
89
+ end
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ require_relative "format"
6
+
7
+ module StructuredDataToSql
8
+ # Renders converter progress events as terminal output on the diagnostic
9
+ # stream. It is purely a consumer of the throttled event stream the
10
+ # converters already emit, so it adds no per-record work: on a TTY it keeps
11
+ # one live status line updated in place; elsewhere it prints a plain status
12
+ # line at most every +min_interval+ seconds so logs show the run is alive
13
+ # without flooding. Finished files become fixed ledger rows, warnings are
14
+ # printed as they arrive, and a summary closes the run.
15
+ class ProgressReporter
16
+ TTY_INTERVAL = 0.5
17
+ PLAIN_INTERVAL = 30
18
+ ETA_WARMUP_SECONDS = 5
19
+ NAME_WIDTH = 28
20
+ PLAIN_WIDTH = 100
21
+ MIN_WRAP_WIDTH = 40
22
+ BLOCK_INDENT = " "
23
+ MAX_ITEM_LINES = 3
24
+
25
+ GLYPHS = {
26
+ unicode: {
27
+ done: "✓",
28
+ live: "⟳",
29
+ warn: "!"
30
+ },
31
+ ascii: {
32
+ done: "[ok]",
33
+ live: "[..]",
34
+ warn: "[!!]"
35
+ }
36
+ }.freeze
37
+
38
+ def initialize(
39
+ io:,
40
+ tty: nil,
41
+ min_interval: nil,
42
+ ascii: nil,
43
+ profile: nil,
44
+ clock: nil
45
+ )
46
+ @io = io
47
+ @tty = tty.nil? ? (io.respond_to?(:tty?) && io.tty?) : tty
48
+ @min_interval = min_interval || (@tty ? TTY_INTERVAL : PLAIN_INTERVAL)
49
+ @glyphs =
50
+ GLYPHS.fetch(
51
+ ascii.nil? ? (@tty ? :unicode : :ascii) : (ascii ? :ascii : :unicode)
52
+ )
53
+ @profile = profile
54
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
55
+ @live_line = nil
56
+ @last_status_at = nil
57
+ @total_input_bytes = nil
58
+ @last_line_blank = false
59
+ end
60
+
61
+ # Recommended converter throttle so the event stream matches the display
62
+ # cadence; the converters only build a payload when this interval passes.
63
+ def converter_interval
64
+ @min_interval
65
+ end
66
+
67
+ def call(event)
68
+ case event.type
69
+ when :start
70
+ start(event)
71
+ when :file_start
72
+ file_start(event)
73
+ when :pass_complete
74
+ @last_status_at = nil if @tty
75
+ status(event)
76
+ when :bytes, :rows, :table
77
+ status(event)
78
+ when :file_complete
79
+ file_complete(event)
80
+ when :warning
81
+ warning(event)
82
+ when :complete
83
+ complete(event)
84
+ end
85
+ rescue IOError, Errno::EPIPE
86
+ nil
87
+ end
88
+
89
+ private
90
+
91
+ def start(event)
92
+ files = event[:file_count]
93
+ total = event[:total_input_bytes]
94
+ @total_input_bytes = total
95
+ parts = ["Converting"]
96
+ parts << "#{Format.format_count(files)} file(s)" if files
97
+ parts << "· #{Format.format_size(total)}" if total.to_i.positive?
98
+ parts << "→ #{event[:output_path]}" if event[:output_path]
99
+ line(parts.join(" "))
100
+ line("Profile: #{@profile}") if @profile
101
+ line("")
102
+ end
103
+
104
+ # A new file restarts the live line immediately; plain logs keep their
105
+ # steady cadence across file boundaries so small files add no lines.
106
+ def file_start(_event)
107
+ @last_status_at = nil if @tty
108
+ end
109
+
110
+ def status(event)
111
+ file = event[:current_file]
112
+ return unless file
113
+
114
+ now = @clock.call
115
+ return if @last_status_at && now - @last_status_at < @min_interval
116
+
117
+ @last_status_at = now
118
+ text = status_text(event, file)
119
+ @tty ? show_live(text) : line(text)
120
+ end
121
+
122
+ def status_text(event, file)
123
+ parts = [" #{@glyphs[:live]} #{file[:name]} #{position(file)}".rstrip]
124
+ parts << "pass #{file[:pass]}/#{file[:passes]}" if file[:passes].to_i > 1
125
+ if file[:size].to_i.positive?
126
+ passes = [file[:passes].to_i, 1].max
127
+ pass_size = file[:size] / passes
128
+ pass_read =
129
+ file[:bytes_read].to_i - ((file[:pass].to_i - 1) * pass_size)
130
+ pass_read = pass_read.clamp(0, pass_size)
131
+ parts << percent(pass_read, pass_size)
132
+ parts << "#{Format.format_size(pass_read)}/#{Format.format_size(pass_size)}"
133
+ end
134
+ elapsed = event[:elapsed].to_f
135
+ work_read = event[:work_bytes_read]
136
+ work_total = event[:total_work_bytes]
137
+ rate = Format.format_rate(work_read, elapsed) if work_read
138
+ parts << rate if rate
139
+ tail = []
140
+ if work_total.to_i.positive? && work_read
141
+ tail << "overall #{percent(work_read, work_total)}"
142
+ end
143
+ tail << "#{Format.format_duration(elapsed)} elapsed"
144
+ eta = eta_text(work_read, work_total, elapsed)
145
+ tail << eta if eta
146
+ "#{parts.join(" ")} · #{tail.join(" · ")}"
147
+ end
148
+
149
+ def eta_text(work_read, work_total, elapsed)
150
+ return nil unless work_total.to_i.positive? && work_read.to_i.positive?
151
+ return nil if elapsed < ETA_WARMUP_SECONDS
152
+
153
+ remaining = work_total - work_read
154
+ return nil if remaining <= 0
155
+
156
+ seconds = remaining * elapsed / work_read.to_f
157
+ return nil if seconds < 1
158
+
159
+ "~#{Format.format_duration(seconds)} left"
160
+ end
161
+
162
+ def file_complete(event)
163
+ file = event[:current_file] || {}
164
+ clear_live
165
+ size = file[:size].to_i / [file[:passes].to_i, 1].max
166
+ rows = event[:file_rows] || file[:record_count]
167
+ row_text = rows ? "#{Format.format_count(rows)} rows" : ""
168
+ row_text +=
169
+ " (+#{Format.format_count(event[:file_child_rows])} child)" if event[
170
+ :file_child_rows
171
+ ].to_i.positive?
172
+ elapsed = event[:file_elapsed]
173
+ line(
174
+ format(
175
+ " %s %-#{NAME_WIDTH}s %10s %30s %8s",
176
+ @glyphs[:done],
177
+ file[:name].to_s,
178
+ size.positive? ? Format.format_size(size) : "",
179
+ row_text,
180
+ elapsed ? Format.format_duration(elapsed) : ""
181
+ ).rstrip
182
+ )
183
+ end
184
+
185
+ # A structured diagnostic renders as a block: title with headline count,
186
+ # wrapped summary, an abbreviated item list, detail lines, and the action.
187
+ # The complete lists live in the diagnostics report. Plain warnings keep
188
+ # the single line.
189
+ def warning(event)
190
+ clear_live
191
+ diagnostic = event[:diagnostic]
192
+ if diagnostic.nil? || diagnostic.code == :generic
193
+ return line(" #{@glyphs[:warn]} #{event[:message]}")
194
+ end
195
+
196
+ title = " #{@glyphs[:warn]} #{diagnostic.title}"
197
+ title += " (#{Format.format_count(diagnostic.count)})" if diagnostic.count
198
+ line(title)
199
+ wrap(diagnostic.summary).each { |text| line(text) } if diagnostic.summary
200
+ item_lines(diagnostic).each { |text| line(text) }
201
+ diagnostic.details.each do |detail|
202
+ wrap(detail, first_prefix: BLOCK_INDENT).each { |text| line(text) }
203
+ end
204
+ if diagnostic.action
205
+ wrap(
206
+ diagnostic.action,
207
+ first_prefix: "#{BLOCK_INDENT}Action: "
208
+ ).each { |text| line(text) }
209
+ end
210
+ line("")
211
+ end
212
+
213
+ def item_lines(diagnostic)
214
+ return [] if diagnostic.items.empty?
215
+ unless diagnostic.terminal_items
216
+ return [
217
+ "#{BLOCK_INDENT}#{diagnostic.items_label || "Items"}: #{Format.format_count(diagnostic.items.length)} listed in the diagnostics report"
218
+ ]
219
+ end
220
+
221
+ label = "#{diagnostic.items_label || "Items"}: "
222
+ lines =
223
+ wrap(diagnostic.items.join(", "), first_prefix: BLOCK_INDENT + label)
224
+ return lines if lines.length <= MAX_ITEM_LINES
225
+
226
+ width = wrap_width
227
+ shown = lines.first(MAX_ITEM_LINES)
228
+ kept = shown.sum { |text| text.scan(/[^,\s]+(?=,|\z)/).length }
229
+ last = shown[-1].sub(/,?\s*\z/, "")
230
+ loop do
231
+ suffix =
232
+ ", … (+#{Format.format_count(diagnostic.items.length - kept)} more)"
233
+ break if (last + suffix).length <= width || !last.include?(", ")
234
+
235
+ last = last.sub(/,\s*[^,]*\z/, "")
236
+ kept -= 1
237
+ end
238
+ shown[
239
+ -1
240
+ ] = "#{last}, … (+#{Format.format_count(diagnostic.items.length - kept)} more)"
241
+ shown
242
+ end
243
+
244
+ def wrap_width
245
+ [(terminal_width || PLAIN_WIDTH), MIN_WRAP_WIDTH].max
246
+ end
247
+
248
+ def wrap(text, first_prefix: BLOCK_INDENT)
249
+ width = wrap_width
250
+ continuation = " " * first_prefix.length
251
+ lines = []
252
+ current = first_prefix.dup
253
+ text
254
+ .to_s
255
+ .split(/\s+/)
256
+ .each do |word|
257
+ if current.length > first_prefix.length &&
258
+ current.length + 1 + word.length > width
259
+ lines << current
260
+ current = continuation.dup
261
+ end
262
+ current << (current.length > continuation.length ? " " : "") << word
263
+ end
264
+ lines << current
265
+ lines
266
+ end
267
+
268
+ def complete(event)
269
+ clear_live
270
+ line("") unless @last_line_blank
271
+ head = ["Complete:"]
272
+ if event[:files_processed]
273
+ head << "#{Format.format_count(event[:files_processed])} file(s),"
274
+ end
275
+ rows = "#{Format.format_count(event[:rows_processed])} rows"
276
+ if event[:child_rows_processed].to_i.positive?
277
+ rows +=
278
+ " (+#{Format.format_count(event[:child_rows_processed])} child rows)"
279
+ end
280
+ head << rows
281
+ if event[:tables_processed]
282
+ head[-1] += ","
283
+ head << "#{Format.format_count(event[:tables_processed])} tables"
284
+ end
285
+ line(head.join(" "))
286
+
287
+ details = []
288
+ read = @total_input_bytes || event[:bytes_read]
289
+ details << "read #{Format.format_size(read)}" if read
290
+ if event[:bytes_written]
291
+ details << "wrote #{Format.format_size(event[:bytes_written])}"
292
+ end
293
+ details << Format.format_duration(event[:elapsed]) if event[:elapsed]
294
+ warnings = event[:warnings_count].to_i
295
+ details << "#{warnings} warning(s)#{warnings.positive? ? " (see above)" : ""}"
296
+ line(" #{details.join(" · ")}")
297
+ if event[:diagnostics_report]
298
+ line("Full diagnostics: #{event[:diagnostics_report]}")
299
+ end
300
+ end
301
+
302
+ def position(file)
303
+ return "" unless file[:index] && file[:count]
304
+
305
+ "(#{file[:index]}/#{file[:count]})"
306
+ end
307
+
308
+ def percent(part, whole)
309
+ return "" unless whole.to_i.positive?
310
+
311
+ value = [part.to_f * 100.0 / whole, 100.0].min
312
+ format("%d%%", value)
313
+ end
314
+
315
+ def show_live(text)
316
+ width = terminal_width
317
+ if width && width > 20 && text.length >= width
318
+ text = "#{text[0, width - 2]}…"
319
+ end
320
+ @io.write("\r#{text}\e[K")
321
+ @io.flush if @io.respond_to?(:flush)
322
+ @live_line = text
323
+ end
324
+
325
+ def clear_live
326
+ return unless @live_line
327
+
328
+ @io.write("\r\e[K")
329
+ @live_line = nil
330
+ end
331
+
332
+ def line(text)
333
+ clear_live
334
+ @io.puts(text)
335
+ @io.flush if @io.respond_to?(:flush)
336
+ @last_line_blank = text.to_s.empty?
337
+ end
338
+
339
+ def terminal_width
340
+ return nil unless @tty && @io.respond_to?(:winsize)
341
+
342
+ width = @io.winsize[1].to_i
343
+ width.positive? ? width : nil
344
+ rescue StandardError
345
+ nil
346
+ end
347
+ end
348
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredDataToSql
4
+ # MySQL/MariaDB literal and identifier escaping shared by the XML and JSON
5
+ # dump converters.
6
+ module SqlText
7
+ module_function
8
+
9
+ def escape_sql_string(value)
10
+ return "NULL" if value.nil?
11
+
12
+ escaped =
13
+ value
14
+ .to_s
15
+ .gsub("\\", "\\\\\\")
16
+ .gsub("'", "\\\\'")
17
+ .gsub("\n", "\\n")
18
+ .gsub("\r", "\\r")
19
+ .gsub("\t", "\\t")
20
+ .gsub("\x00", "\\0")
21
+ .gsub("\x1a", "\\Z")
22
+ "'#{escaped}'"
23
+ end
24
+
25
+ def escape_identifier(name)
26
+ "`#{name.to_s.gsub("`", "``")}`"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredDataToSql
4
+ VERSION = "0.1.0"
5
+ end