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,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "profiles/khoros_api_export"
|
|
4
|
+
|
|
5
|
+
module StructuredDataToSql
|
|
6
|
+
module Json
|
|
7
|
+
# Preparation profiles selectable through the converter's +profile:+
|
|
8
|
+
# option (CLI --profile). A profile class provides NAME, REQUIRED_SCHEMA,
|
|
9
|
+
# an InputValidator, .write_preamble(out), and #write(out).
|
|
10
|
+
module Profiles
|
|
11
|
+
REGISTRY = { KhorosApiExport::NAME => KhorosApiExport }.freeze
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "oj"
|
|
4
|
+
|
|
5
|
+
module StructuredDataToSql
|
|
6
|
+
module Json
|
|
7
|
+
# Streams the records out of one JSON document without materializing the
|
|
8
|
+
# whole document: only the envelope (everything outside the records array)
|
|
9
|
+
# and one record at a time are held in memory.
|
|
10
|
+
#
|
|
11
|
+
# Records-array detection, first match wins:
|
|
12
|
+
# 1. the array at the end of the explicit dot-notation records_path
|
|
13
|
+
# (e.g. "data.community.roles.edges"); error if absent
|
|
14
|
+
# 2. the array under the top-level "data" key
|
|
15
|
+
# 3. the sole top-level array value (buffered into the envelope first,
|
|
16
|
+
# so only suitable for modest non-"data" files; use records_path for
|
|
17
|
+
# huge ones)
|
|
18
|
+
# 4. none: the whole document is a single record
|
|
19
|
+
#
|
|
20
|
+
# NDJSON (newline-delimited JSON) support:
|
|
21
|
+
# When ndjson: true is passed, or auto-detection determines the stream
|
|
22
|
+
# contains multiple top-level JSON documents (one per line), each line is
|
|
23
|
+
# parsed independently and records from all lines are yielded in order.
|
|
24
|
+
# All lines must share the same structure (same records_path). The
|
|
25
|
+
# envelope is taken from the first non-empty line; subsequent lines'
|
|
26
|
+
# envelopes are ignored (they are structurally identical page wrappers).
|
|
27
|
+
class RecordStreamer
|
|
28
|
+
Result =
|
|
29
|
+
Struct.new(
|
|
30
|
+
:envelope,
|
|
31
|
+
:records_key,
|
|
32
|
+
:record_count,
|
|
33
|
+
:truncated,
|
|
34
|
+
:parse_error,
|
|
35
|
+
:skipped_lines,
|
|
36
|
+
keyword_init: true
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Maximum bytes read for each line considered during NDJSON probing.
|
|
40
|
+
NDJSON_PROBE_LINE_BYTES = 16 * 1024 * 1024
|
|
41
|
+
|
|
42
|
+
def initialize(records_path: nil)
|
|
43
|
+
@records_path = records_path
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Yields each record (a plain Hash/Array/scalar tree) with its 0-based
|
|
47
|
+
# ordinal. Returns a Result with the envelope hash (records excluded).
|
|
48
|
+
#
|
|
49
|
+
# With recover: true, a parse failure inside the records array returns a
|
|
50
|
+
# truncated Result covering the records that completed before the
|
|
51
|
+
# failure (each yielded record is a fully parsed tree, so everything
|
|
52
|
+
# already yielded is intact); the partial tail record is discarded.
|
|
53
|
+
# Failures before the first complete record still raise.
|
|
54
|
+
#
|
|
55
|
+
# With ndjson: true, each line of +io+ is treated as a separate JSON
|
|
56
|
+
# document. Auto-detection (ndjson: :auto, the default) probes the first
|
|
57
|
+
# two non-empty lines; set ndjson: false to disable auto-detection.
|
|
58
|
+
def each_record(io, recover: false, ndjson: :auto, &block)
|
|
59
|
+
if ndjson == true || (ndjson == :auto && ndjson_stream?(io))
|
|
60
|
+
each_record_ndjson(io, recover: recover, &block)
|
|
61
|
+
else
|
|
62
|
+
each_record_single(io, recover: recover, &block)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Single-document path (original behaviour)
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def each_record_single(io, recover: false, &block)
|
|
73
|
+
path_keys, records_key = path_keys_and_root_key
|
|
74
|
+
handler =
|
|
75
|
+
Handler.new(
|
|
76
|
+
records_key: records_key,
|
|
77
|
+
records_path: path_keys,
|
|
78
|
+
on_record: block
|
|
79
|
+
)
|
|
80
|
+
begin
|
|
81
|
+
Oj.sc_parse(handler, io)
|
|
82
|
+
rescue Oj::ParseError, EncodingError => e
|
|
83
|
+
unless recover && handler.records_found &&
|
|
84
|
+
handler.record_count.positive?
|
|
85
|
+
raise
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
return(
|
|
89
|
+
Result.new(
|
|
90
|
+
envelope: handler.envelope || {},
|
|
91
|
+
records_key: handler.records_key_used,
|
|
92
|
+
record_count: handler.record_count,
|
|
93
|
+
truncated: true,
|
|
94
|
+
parse_error: e.message.sub(/ \[\S+\]\z/, "")
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
root = handler.root_value
|
|
99
|
+
if handler.records_found
|
|
100
|
+
return(
|
|
101
|
+
Result.new(
|
|
102
|
+
envelope: handler.envelope || {},
|
|
103
|
+
records_key: handler.records_key_used,
|
|
104
|
+
record_count: handler.record_count
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if @records_path
|
|
110
|
+
raise UsageError,
|
|
111
|
+
"No array found under records path '#{@records_path}' in JSON input"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
fallback_records(root, handler, &block)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# NDJSON path — one JSON document per line
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def each_record_ndjson(io, recover: false, &block)
|
|
122
|
+
total_count = 0
|
|
123
|
+
envelope = nil
|
|
124
|
+
records_key_used = nil
|
|
125
|
+
truncated = false
|
|
126
|
+
parse_error = nil
|
|
127
|
+
skipped_lines = 0
|
|
128
|
+
line_number = 0
|
|
129
|
+
|
|
130
|
+
io.each_line do |line|
|
|
131
|
+
line_number += 1
|
|
132
|
+
next if line.empty?
|
|
133
|
+
next if line.getbyte(0) <= 32 && line.match?(/\A[[:space:]]*\z/)
|
|
134
|
+
|
|
135
|
+
begin
|
|
136
|
+
root = Oj.strict_load(line)
|
|
137
|
+
rescue Oj::ParseError, EncodingError => e
|
|
138
|
+
if recover
|
|
139
|
+
truncated = true
|
|
140
|
+
skipped_lines += 1
|
|
141
|
+
parse_error = e.message.sub(/ \[\S+\]\z/, "")
|
|
142
|
+
next
|
|
143
|
+
end
|
|
144
|
+
raise
|
|
145
|
+
end
|
|
146
|
+
page_envelope, page_records_key, records =
|
|
147
|
+
ndjson_page(root, line_number)
|
|
148
|
+
records.each do |record|
|
|
149
|
+
block.call(record, total_count)
|
|
150
|
+
total_count += 1
|
|
151
|
+
end
|
|
152
|
+
envelope ||= page_envelope
|
|
153
|
+
records_key_used ||= page_records_key
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if recover && skipped_lines.positive? && total_count.zero?
|
|
157
|
+
raise UsageError,
|
|
158
|
+
"No complete records could be recovered from NDJSON input (skipped #{skipped_lines} malformed line(s))"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
Result.new(
|
|
162
|
+
envelope: envelope || {},
|
|
163
|
+
records_key: records_key_used,
|
|
164
|
+
record_count: total_count,
|
|
165
|
+
truncated: truncated || nil,
|
|
166
|
+
parse_error: parse_error,
|
|
167
|
+
skipped_lines: skipped_lines.positive? ? skipped_lines : nil
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# NDJSON lines are complete JSON documents, so Oj's direct strict parser
|
|
172
|
+
# is substantially cheaper than constructing a streaming handler and IO
|
|
173
|
+
# wrapper for every line. The resulting page is already bounded by one
|
|
174
|
+
# line; select its records with the same rules as the streaming path.
|
|
175
|
+
def ndjson_page(root, line_number)
|
|
176
|
+
if @records_path
|
|
177
|
+
path = @records_path.split(".")
|
|
178
|
+
parent = root
|
|
179
|
+
path[0...-1].each { |key| parent = parent[key] if parent.is_a?(Hash) }
|
|
180
|
+
records = parent.delete(path.last) if parent.is_a?(Hash)
|
|
181
|
+
unless records.is_a?(Array)
|
|
182
|
+
raise UsageError,
|
|
183
|
+
"No array found under records path '#{@records_path}' in JSON input on NDJSON line #{line_number}"
|
|
184
|
+
end
|
|
185
|
+
return root, @records_path, records
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
return {}, nil, root if root.is_a?(Array)
|
|
189
|
+
unless root.is_a?(Hash)
|
|
190
|
+
raise UsageError,
|
|
191
|
+
"JSON input is a bare scalar value and cannot be converted"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
return root, "data", root.delete("data") if root["data"].is_a?(Array)
|
|
195
|
+
|
|
196
|
+
array_keys = root.keys.select { |key| root[key].is_a?(Array) }
|
|
197
|
+
if array_keys.length == 1
|
|
198
|
+
key = array_keys.first
|
|
199
|
+
return root, key, root.delete(key)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
[{}, nil, [root]]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# NDJSON auto-detection
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
# Returns true if +io+ looks like a newline-delimited multi-document
|
|
210
|
+
# stream. The probe reads up to two non-empty lines and checks whether the
|
|
211
|
+
# first is a complete JSON object/array. The IO is rewound to its original
|
|
212
|
+
# position after probing, so the caller continues from where it started.
|
|
213
|
+
#
|
|
214
|
+
# This works for seekable IO objects. CountingIO wraps readpartial/read but
|
|
215
|
+
# does not expose seek; we reach through to the underlying IO when
|
|
216
|
+
# available, otherwise we fall back to false (no auto-detection). Forced
|
|
217
|
+
# ndjson: true does not require seeking.
|
|
218
|
+
def ndjson_stream?(io)
|
|
219
|
+
# Unwrap CountingIO so we can seek.
|
|
220
|
+
raw_io = io.respond_to?(:__raw_io__) ? io.__raw_io__ : io
|
|
221
|
+
unless raw_io.respond_to?(:seek) && raw_io.respond_to?(:gets) &&
|
|
222
|
+
raw_io.respond_to?(:pos)
|
|
223
|
+
return false
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
original_pos = raw_io.pos
|
|
227
|
+
raw_io.seek(original_pos)
|
|
228
|
+
lines = []
|
|
229
|
+
while lines.length < 2
|
|
230
|
+
line = raw_io.gets(NDJSON_PROBE_LINE_BYTES)
|
|
231
|
+
break if line.nil?
|
|
232
|
+
|
|
233
|
+
lines << line unless line.strip.empty?
|
|
234
|
+
end
|
|
235
|
+
return false if lines.length < 2
|
|
236
|
+
|
|
237
|
+
first_line = lines.first
|
|
238
|
+
|
|
239
|
+
# The first line must be a syntactically complete JSON object/array.
|
|
240
|
+
begin
|
|
241
|
+
first_value = Oj.load(first_line)
|
|
242
|
+
rescue Oj::ParseError, EncodingError
|
|
243
|
+
return false
|
|
244
|
+
end
|
|
245
|
+
return false unless first_value.is_a?(Hash) || first_value.is_a?(Array)
|
|
246
|
+
|
|
247
|
+
# There is more content after that first line.
|
|
248
|
+
true
|
|
249
|
+
rescue IOError, SystemCallError
|
|
250
|
+
false
|
|
251
|
+
ensure
|
|
252
|
+
begin
|
|
253
|
+
raw_io.seek(original_pos) if raw_io && original_pos
|
|
254
|
+
rescue IOError, SystemCallError
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Shared helpers
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def path_keys_and_root_key
|
|
264
|
+
path_keys = @records_path ? @records_path.split(".") : nil
|
|
265
|
+
# Single-key path: legacy behaviour — treat it as the flat records key
|
|
266
|
+
# at depth 1 (same as before this change, so old callers are unaffected).
|
|
267
|
+
records_key =
|
|
268
|
+
(path_keys && path_keys.length == 1) ? path_keys.first : "data"
|
|
269
|
+
[path_keys, records_key]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def fallback_records(root, handler, &block)
|
|
273
|
+
unless root.is_a?(Hash)
|
|
274
|
+
raise UsageError,
|
|
275
|
+
"JSON input is a bare scalar value and cannot be converted"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
envelope = handler.envelope
|
|
279
|
+
|
|
280
|
+
array_keys = envelope.keys.select { |key| envelope[key].is_a?(Array) }
|
|
281
|
+
if array_keys.length == 1
|
|
282
|
+
key = array_keys.first
|
|
283
|
+
records = envelope.delete(key)
|
|
284
|
+
records.each_with_index(&block)
|
|
285
|
+
Result.new(
|
|
286
|
+
envelope: envelope,
|
|
287
|
+
records_key: key,
|
|
288
|
+
record_count: records.length
|
|
289
|
+
)
|
|
290
|
+
else
|
|
291
|
+
block.call(envelope, 0)
|
|
292
|
+
Result.new(envelope: {}, records_key: nil, record_count: 1)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# -- Oj::ScHandler callback names
|
|
297
|
+
class Handler < ::Oj::ScHandler
|
|
298
|
+
RECORDS = Object.new
|
|
299
|
+
|
|
300
|
+
attr_reader :envelope, :records_key_used, :record_count, :root_value
|
|
301
|
+
|
|
302
|
+
# +records_key+ is the single-key fallback used to match a depth-1 array
|
|
303
|
+
# (kept for legacy single-key paths and the default "data" case).
|
|
304
|
+
# +records_path+ is the full key sequence for multi-level paths; when set,
|
|
305
|
+
# the handler descends through intermediate objects until the final segment
|
|
306
|
+
# matches an array — that array becomes the RECORDS stream.
|
|
307
|
+
def initialize(records_key:, on_record:, records_path: nil)
|
|
308
|
+
super()
|
|
309
|
+
@records_key = records_key
|
|
310
|
+
@records_path = records_path
|
|
311
|
+
@on_record = on_record
|
|
312
|
+
@stack = []
|
|
313
|
+
@pending_key = nil
|
|
314
|
+
@envelope = nil
|
|
315
|
+
@records_found = false
|
|
316
|
+
@records_key_used = nil
|
|
317
|
+
@record_count = 0
|
|
318
|
+
@root_value = nil
|
|
319
|
+
# Number of path segments matched by the current object branch.
|
|
320
|
+
@path_matched = 0
|
|
321
|
+
@path_match_stack = []
|
|
322
|
+
# The most recent key seen at the current path match depth.
|
|
323
|
+
@path_pending_key = nil
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def records_found
|
|
327
|
+
@records_found
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def hash_start
|
|
331
|
+
hash = {}
|
|
332
|
+
previous_path_matched = @path_matched
|
|
333
|
+
@path_matched += 1 if path_intermediate_value?
|
|
334
|
+
@path_match_stack.push(previous_path_matched)
|
|
335
|
+
@envelope = hash if @stack.empty?
|
|
336
|
+
@stack.push(hash)
|
|
337
|
+
hash
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def hash_end
|
|
341
|
+
obj = @stack.pop
|
|
342
|
+
@path_matched = @path_match_stack.pop || 0
|
|
343
|
+
obj
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def hash_key(key)
|
|
347
|
+
# Legacy single-level match: track the pending key at depth 1.
|
|
348
|
+
@pending_key = key if @stack.length == 1
|
|
349
|
+
|
|
350
|
+
# Multi-level path: track the key seen at the current match depth,
|
|
351
|
+
# and advance the match counter for non-final segments.
|
|
352
|
+
if @records_path && !@records_found
|
|
353
|
+
@path_pending_key = key if @stack.length == @path_matched + 1
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
key
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def hash_set(hash, key, value)
|
|
360
|
+
hash[key] = value unless value.equal?(RECORDS)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def array_start
|
|
364
|
+
target =
|
|
365
|
+
if @stack.empty?
|
|
366
|
+
@records_found = true
|
|
367
|
+
RECORDS
|
|
368
|
+
elsif multi_level_records_array?
|
|
369
|
+
@records_found = true
|
|
370
|
+
@records_key_used = @records_path.join(".")
|
|
371
|
+
RECORDS
|
|
372
|
+
elsif @stack.length == 1 && @stack.first.equal?(@envelope) &&
|
|
373
|
+
@pending_key == @records_key
|
|
374
|
+
@records_found = true
|
|
375
|
+
@records_key_used = @records_key
|
|
376
|
+
RECORDS
|
|
377
|
+
else
|
|
378
|
+
[]
|
|
379
|
+
end
|
|
380
|
+
@stack.push(target)
|
|
381
|
+
target
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def array_end
|
|
385
|
+
@stack.pop
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def array_append(array, value)
|
|
389
|
+
if array.equal?(RECORDS)
|
|
390
|
+
@on_record.call(value, @record_count)
|
|
391
|
+
@record_count += 1
|
|
392
|
+
else
|
|
393
|
+
array << value
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def add_value(value)
|
|
398
|
+
@root_value = value
|
|
399
|
+
@envelope = {} if @envelope.nil?
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
private
|
|
403
|
+
|
|
404
|
+
# A key matching an intermediate path segment only counts after its
|
|
405
|
+
# value actually starts as an object. This keeps partial matches from
|
|
406
|
+
# leaking into sibling branches or across scalar/array values.
|
|
407
|
+
def path_intermediate_value?
|
|
408
|
+
return false unless @records_path && !@records_found
|
|
409
|
+
return false if @stack.empty?
|
|
410
|
+
return false if @path_matched >= @records_path.length - 1
|
|
411
|
+
|
|
412
|
+
@stack.length == @path_matched + 1 &&
|
|
413
|
+
@path_pending_key == @records_path[@path_matched]
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Returns true when the multi-level path has been fully matched and the
|
|
417
|
+
# current pending_key is the final segment — meaning this array_start
|
|
418
|
+
# call is the records array.
|
|
419
|
+
def multi_level_records_array?
|
|
420
|
+
return false unless @records_path && !@records_found
|
|
421
|
+
|
|
422
|
+
# All path segments except the last have been matched via hash keys;
|
|
423
|
+
# the current @path_pending_key is the final segment.
|
|
424
|
+
@path_matched == @records_path.length - 1 &&
|
|
425
|
+
@path_pending_key == @records_path.last &&
|
|
426
|
+
@stack.length == @path_matched + 1
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
# rubocop:enable Naming/MethodName
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# IO wrapper reporting consumed bytes; Oj.sc_parse drives custom IO-like
|
|
433
|
+
# objects through readpartial.
|
|
434
|
+
class CountingIO
|
|
435
|
+
def initialize(io, &on_bytes)
|
|
436
|
+
@io = io
|
|
437
|
+
@on_bytes = on_bytes
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Exposed so RecordStreamer can reach the underlying IO for seek-based
|
|
441
|
+
# NDJSON probe/rewind without going through the counting wrapper.
|
|
442
|
+
def __raw_io__
|
|
443
|
+
@io
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def readpartial(max_length, out_buffer = nil)
|
|
447
|
+
chunk =
|
|
448
|
+
(
|
|
449
|
+
if out_buffer
|
|
450
|
+
@io.readpartial(max_length, out_buffer)
|
|
451
|
+
else
|
|
452
|
+
@io.readpartial(max_length)
|
|
453
|
+
end
|
|
454
|
+
)
|
|
455
|
+
@on_bytes.call(chunk.bytesize) if chunk
|
|
456
|
+
chunk
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def read(length = nil, out_buffer = nil)
|
|
460
|
+
chunk = out_buffer ? @io.read(length, out_buffer) : @io.read(length)
|
|
461
|
+
@on_bytes.call(chunk.bytesize) if chunk
|
|
462
|
+
chunk
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Delegates each_line to the underlying IO, reporting bytes as each line
|
|
466
|
+
# is yielded so progress tracking still works during NDJSON iteration.
|
|
467
|
+
def each_line(&block)
|
|
468
|
+
return enum_for(:each_line) unless block
|
|
469
|
+
|
|
470
|
+
@io.each_line do |line|
|
|
471
|
+
@on_bytes.call(line.bytesize)
|
|
472
|
+
block.call(line)
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module StructuredDataToSql
|
|
6
|
+
module Json
|
|
7
|
+
ColumnDef =
|
|
8
|
+
Struct.new(:name, :kind, :sql_type, :null, :comment, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# Accumulates per-column type observations across all records of a table
|
|
11
|
+
# and finalizes them into MySQL column definitions via a widening lattice:
|
|
12
|
+
# unknown -> boolean | integer -> float | datetime -> string (sized into
|
|
13
|
+
# VARCHAR/TEXT/MEDIUMTEXT/LONGTEXT by max byte length).
|
|
14
|
+
class ColumnProfile
|
|
15
|
+
ISO_DATETIME =
|
|
16
|
+
/\A\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:?\d{2})?\z/
|
|
17
|
+
|
|
18
|
+
attr_reader :kind, :seen_count
|
|
19
|
+
|
|
20
|
+
def initialize(raw_dates: false)
|
|
21
|
+
@raw_dates = raw_dates
|
|
22
|
+
@kind = :unknown
|
|
23
|
+
@max_bytes = 0
|
|
24
|
+
@null_seen = false
|
|
25
|
+
@seen_count = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def observe(value)
|
|
29
|
+
@seen_count += 1
|
|
30
|
+
case value
|
|
31
|
+
when nil
|
|
32
|
+
@null_seen = true
|
|
33
|
+
when JsonValue
|
|
34
|
+
@kind = :json
|
|
35
|
+
@max_bytes = [@max_bytes, value.text.bytesize].max
|
|
36
|
+
when true, false
|
|
37
|
+
widen(:boolean)
|
|
38
|
+
when Integer
|
|
39
|
+
widen(:integer)
|
|
40
|
+
when Float
|
|
41
|
+
widen(:float)
|
|
42
|
+
when String
|
|
43
|
+
@max_bytes = [@max_bytes, value.bytesize].max
|
|
44
|
+
widen(!@raw_dates && value.match?(ISO_DATETIME) ? :datetime : :string)
|
|
45
|
+
else
|
|
46
|
+
@max_bytes = [@max_bytes, value.to_s.bytesize].max
|
|
47
|
+
widen(:string)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def finalize(name, row_count)
|
|
52
|
+
ColumnDef.new(
|
|
53
|
+
name: name,
|
|
54
|
+
kind: @kind,
|
|
55
|
+
sql_type: sql_type,
|
|
56
|
+
null: @null_seen || @seen_count < row_count
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def widen(observed)
|
|
63
|
+
return @kind = :json if @kind == :json || observed == :json
|
|
64
|
+
return @kind = observed if @kind == :unknown
|
|
65
|
+
return if @kind == observed
|
|
66
|
+
|
|
67
|
+
numeric = [@kind, observed].sort == %i[float integer]
|
|
68
|
+
@kind = numeric ? :float : :string
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def sql_type
|
|
72
|
+
case @kind
|
|
73
|
+
when :boolean
|
|
74
|
+
"TINYINT(1)"
|
|
75
|
+
when :integer
|
|
76
|
+
"BIGINT"
|
|
77
|
+
when :float
|
|
78
|
+
"DOUBLE"
|
|
79
|
+
when :datetime
|
|
80
|
+
"DATETIME"
|
|
81
|
+
when :json
|
|
82
|
+
"LONGTEXT"
|
|
83
|
+
else
|
|
84
|
+
string_type
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def string_type
|
|
89
|
+
if @max_bytes <= 255
|
|
90
|
+
"VARCHAR(255)"
|
|
91
|
+
elsif @max_bytes <= 65_535
|
|
92
|
+
"TEXT"
|
|
93
|
+
elsif @max_bytes <= 16_777_215
|
|
94
|
+
"MEDIUMTEXT"
|
|
95
|
+
else
|
|
96
|
+
"LONGTEXT"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Column profiles for one table, in first-observation order.
|
|
102
|
+
class TableProfile
|
|
103
|
+
attr_reader :table_rel, :row_count
|
|
104
|
+
|
|
105
|
+
def initialize(table_rel, raw_dates: false)
|
|
106
|
+
@table_rel = table_rel
|
|
107
|
+
@raw_dates = raw_dates
|
|
108
|
+
@columns = {}
|
|
109
|
+
@row_count = 0
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def observe_row(columns)
|
|
113
|
+
@row_count += 1
|
|
114
|
+
columns.each do |name, value|
|
|
115
|
+
(@columns[name] ||= ColumnProfile.new(raw_dates: @raw_dates)).observe(
|
|
116
|
+
value
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def child?
|
|
122
|
+
!@table_rel.empty?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def finalize
|
|
126
|
+
@columns.map { |name, profile| profile.finalize(name, @row_count) }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Collects TableProfiles for every table produced by shredding a file.
|
|
131
|
+
class SchemaInferrer
|
|
132
|
+
attr_reader :profiles
|
|
133
|
+
|
|
134
|
+
def initialize(raw_dates: false)
|
|
135
|
+
@raw_dates = raw_dates
|
|
136
|
+
@profiles = {}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def observe(row)
|
|
140
|
+
(
|
|
141
|
+
@profiles[row.table_rel] ||= TableProfile.new(
|
|
142
|
+
row.table_rel,
|
|
143
|
+
raw_dates: @raw_dates
|
|
144
|
+
)
|
|
145
|
+
).observe_row(row.columns)
|
|
146
|
+
row.children.each { |child| observe(child) }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|