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,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "oj"
|
|
4
|
+
|
|
5
|
+
module StructuredDataToSql
|
|
6
|
+
module Json
|
|
7
|
+
# Builds table definitions directly from a Draft-07 JSON Schema file,
|
|
8
|
+
# walking the same flattening/unwrap rules as the Shredder (and
|
|
9
|
+
# registering every name in the shared NameRegistry so the emit pass
|
|
10
|
+
# produces identical names). x-pii annotations become column comments
|
|
11
|
+
# plus rows for the dump-wide _json_meta manifest.
|
|
12
|
+
class JsonSchemaLoader
|
|
13
|
+
TablePlan = Struct.new(:rel, :defs, :child, keyword_init: true)
|
|
14
|
+
PiiEntry =
|
|
15
|
+
Struct.new(:table_rel, :column, :json_path, :note, keyword_init: true)
|
|
16
|
+
Result =
|
|
17
|
+
Struct.new(:plans, :root_name, :pii, :forced_json, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
MIN_ENUM_VARCHAR = 32
|
|
20
|
+
|
|
21
|
+
def initialize(
|
|
22
|
+
registry:,
|
|
23
|
+
max_depth: 5,
|
|
24
|
+
graphql_unwrap: true,
|
|
25
|
+
raw_dates: false
|
|
26
|
+
)
|
|
27
|
+
@registry = registry
|
|
28
|
+
@max_depth = max_depth
|
|
29
|
+
@graphql_unwrap = graphql_unwrap
|
|
30
|
+
@raw_dates = raw_dates
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def load(schema_path, records_path: nil)
|
|
34
|
+
begin
|
|
35
|
+
@schema = Oj.load_file(schema_path.to_s, mode: :strict)
|
|
36
|
+
rescue Oj::ParseError, EncodingError => e
|
|
37
|
+
raise UsageError,
|
|
38
|
+
"Could not parse JSON Schema #{schema_path}: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
unless @schema.is_a?(Hash)
|
|
41
|
+
raise UsageError, "JSON Schema #{schema_path} is not an object schema"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@plans = {}
|
|
45
|
+
@pii = []
|
|
46
|
+
@forced_json = Set.new
|
|
47
|
+
record_schema = detect_record_schema(records_path)
|
|
48
|
+
walk_object(resolve(record_schema), [], "", parent_required: true)
|
|
49
|
+
Result.new(
|
|
50
|
+
plans: @plans,
|
|
51
|
+
root_name: root_name,
|
|
52
|
+
pii: @pii,
|
|
53
|
+
forced_json: @forced_json
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def root_name
|
|
60
|
+
properties = @schema["properties"] || {}
|
|
61
|
+
const = properties.dig("object_name", "const")
|
|
62
|
+
const.is_a?(String) ? const : nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def detect_record_schema(records_path)
|
|
66
|
+
properties = @schema["properties"]
|
|
67
|
+
return @schema unless properties.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
if records_path
|
|
70
|
+
candidate = schema_at_path(records_path.split("."))
|
|
71
|
+
candidate = resolve(candidate) if candidate
|
|
72
|
+
if candidate && array_schema?(candidate)
|
|
73
|
+
return candidate["items"] || {}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
raise UsageError,
|
|
77
|
+
"JSON Schema has no array under records path '#{records_path}'"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
candidate = properties["data"]
|
|
81
|
+
if candidate.nil?
|
|
82
|
+
array_keys =
|
|
83
|
+
properties.keys.select do |name|
|
|
84
|
+
array_schema?(resolve(properties[name]))
|
|
85
|
+
end
|
|
86
|
+
candidate = properties[array_keys.first] if array_keys.length == 1
|
|
87
|
+
end
|
|
88
|
+
candidate = resolve(candidate) if candidate
|
|
89
|
+
return candidate["items"] || {} if candidate && array_schema?(candidate)
|
|
90
|
+
|
|
91
|
+
@schema
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def schema_at_path(parts)
|
|
95
|
+
parts.reduce(@schema) do |schema, part|
|
|
96
|
+
resolved = resolve(schema)
|
|
97
|
+
properties = resolved["properties"]
|
|
98
|
+
return nil unless properties.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
properties[part]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def array_schema?(schema)
|
|
105
|
+
types(schema).include?("array")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def types(schema)
|
|
109
|
+
Array(schema["type"]).map(&:to_s)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def resolve(schema, visited = Set.new)
|
|
113
|
+
ref = schema.is_a?(Hash) ? schema["$ref"] : nil
|
|
114
|
+
return schema unless ref
|
|
115
|
+
|
|
116
|
+
unless ref.start_with?("#/")
|
|
117
|
+
raise UsageError, "Unsupported JSON Schema $ref: #{ref}"
|
|
118
|
+
end
|
|
119
|
+
if visited.include?(ref)
|
|
120
|
+
raise UsageError, "Circular JSON Schema $ref: #{ref}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
visited << ref
|
|
124
|
+
target =
|
|
125
|
+
ref
|
|
126
|
+
.delete_prefix("#/")
|
|
127
|
+
.split("/")
|
|
128
|
+
.reduce(@schema) do |node, part|
|
|
129
|
+
node.is_a?(Hash) ? node[part] : nil
|
|
130
|
+
end
|
|
131
|
+
unless target.is_a?(Hash)
|
|
132
|
+
raise UsageError, "Unresolvable JSON Schema $ref: #{ref}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
resolve(target, visited)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def plan_for(rel)
|
|
139
|
+
@plans[rel] ||= TablePlan.new(rel: rel, defs: [], child: !rel.empty?)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def walk_object(schema, path, rel, parent_required:)
|
|
143
|
+
plan_for(rel)
|
|
144
|
+
properties = schema["properties"]
|
|
145
|
+
return unless properties.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
required = Array(schema["required"])
|
|
148
|
+
properties.each do |key, raw_sub|
|
|
149
|
+
sub = resolve(raw_sub)
|
|
150
|
+
new_path = path + [key]
|
|
151
|
+
not_null =
|
|
152
|
+
parent_required && required.include?(key) &&
|
|
153
|
+
!types(sub).include?("null")
|
|
154
|
+
base_types = types(sub) - ["null"]
|
|
155
|
+
|
|
156
|
+
if base_types == ["object"] ||
|
|
157
|
+
(base_types.empty? && sub["properties"].is_a?(Hash))
|
|
158
|
+
handle_object(sub, new_path, rel, not_null: not_null)
|
|
159
|
+
elsif base_types == ["array"]
|
|
160
|
+
handle_array(sub, new_path, rel, not_null: not_null)
|
|
161
|
+
elsif base_types.length == 1 &&
|
|
162
|
+
%w[string integer number boolean].include?(base_types.first)
|
|
163
|
+
add_column(sub, new_path, rel, base_types.first, not_null: not_null)
|
|
164
|
+
else
|
|
165
|
+
# Untyped, mixed-type, or unsupported: keep raw JSON.
|
|
166
|
+
add_json_column(sub, new_path, rel, not_null: not_null)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def handle_object(schema, path, rel, not_null:)
|
|
172
|
+
if @graphql_unwrap && (node_schema = unwrap_edges(schema))
|
|
173
|
+
child_rel = @registry.child_rel(rel, path)
|
|
174
|
+
walk_object(
|
|
175
|
+
resolve(node_schema),
|
|
176
|
+
[],
|
|
177
|
+
child_rel,
|
|
178
|
+
parent_required: true
|
|
179
|
+
)
|
|
180
|
+
elsif !schema["properties"].is_a?(Hash) || path.length >= @max_depth
|
|
181
|
+
add_json_column(schema, path, rel, not_null: not_null)
|
|
182
|
+
else
|
|
183
|
+
walk_object(schema, path, rel, parent_required: not_null)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def handle_array(schema, path, rel, not_null:)
|
|
188
|
+
if path.length >= @max_depth
|
|
189
|
+
return add_json_column(schema, path, rel, not_null: not_null)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
items = schema["items"]
|
|
193
|
+
items = resolve(items) if items.is_a?(Hash)
|
|
194
|
+
item_types = items.is_a?(Hash) ? types(items) - ["null"] : []
|
|
195
|
+
|
|
196
|
+
if item_types == ["object"] ||
|
|
197
|
+
(items.is_a?(Hash) && items["properties"].is_a?(Hash))
|
|
198
|
+
child_rel = @registry.child_rel(rel, path)
|
|
199
|
+
walk_object(items, [], child_rel, parent_required: true)
|
|
200
|
+
elsif item_types.length == 1 &&
|
|
201
|
+
%w[string integer number boolean].include?(item_types.first)
|
|
202
|
+
child_rel = @registry.child_rel(rel, path)
|
|
203
|
+
plan = plan_for(child_rel)
|
|
204
|
+
kind, sql_type = scalar_type(items, item_types.first)
|
|
205
|
+
plan.defs << ColumnDef.new(
|
|
206
|
+
name: "value",
|
|
207
|
+
kind: kind,
|
|
208
|
+
sql_type: sql_type,
|
|
209
|
+
null: true,
|
|
210
|
+
comment: nil
|
|
211
|
+
)
|
|
212
|
+
else
|
|
213
|
+
add_json_column(schema, path, rel, not_null: not_null)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def add_column(schema, path, rel, base_type, not_null:)
|
|
218
|
+
name = @registry.column_name(rel, path, :scalar)
|
|
219
|
+
kind, sql_type = scalar_type(schema, base_type)
|
|
220
|
+
plan_for(rel).defs << ColumnDef.new(
|
|
221
|
+
name: name,
|
|
222
|
+
kind: kind,
|
|
223
|
+
sql_type: sql_type,
|
|
224
|
+
null: !not_null,
|
|
225
|
+
comment: pii_comment(schema)
|
|
226
|
+
)
|
|
227
|
+
record_pii(schema, rel, name, path)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def add_json_column(schema, path, rel, not_null:)
|
|
231
|
+
name = @registry.column_name(rel, path, :json)
|
|
232
|
+
@forced_json << [rel, path.join("\0")]
|
|
233
|
+
plan_for(rel).defs << ColumnDef.new(
|
|
234
|
+
name: name,
|
|
235
|
+
kind: :json,
|
|
236
|
+
sql_type: "LONGTEXT",
|
|
237
|
+
null: !not_null,
|
|
238
|
+
comment: pii_comment(schema)
|
|
239
|
+
)
|
|
240
|
+
record_pii(schema, rel, name, path)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def scalar_type(schema, base_type)
|
|
244
|
+
case base_type
|
|
245
|
+
when "integer"
|
|
246
|
+
[:integer, "BIGINT"]
|
|
247
|
+
when "number"
|
|
248
|
+
[:float, "DOUBLE"]
|
|
249
|
+
when "boolean"
|
|
250
|
+
[:boolean, "TINYINT(1)"]
|
|
251
|
+
else
|
|
252
|
+
string_type(schema)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def string_type(schema)
|
|
257
|
+
if schema["format"] == "date-time" && !@raw_dates
|
|
258
|
+
return :datetime, "DATETIME"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
enum_values = Array(schema["enum"]).compact.map(&:to_s)
|
|
262
|
+
if enum_values.any?
|
|
263
|
+
[
|
|
264
|
+
:string,
|
|
265
|
+
"VARCHAR(#{[enum_values.map(&:length).max, MIN_ENUM_VARCHAR].max})"
|
|
266
|
+
]
|
|
267
|
+
elsif schema["maxLength"].is_a?(Integer) && schema["maxLength"] <= 255
|
|
268
|
+
[:string, "VARCHAR(#{schema["maxLength"]})"]
|
|
269
|
+
else
|
|
270
|
+
[:string, "TEXT"]
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Schema-shape counterpart of the Shredder's GraphQL unwrap. Tolerates
|
|
275
|
+
# Relay metadata siblings like pageInfo/totalCount on the connection and
|
|
276
|
+
# cursor on each edge; only node data is migrated.
|
|
277
|
+
def unwrap_edges(schema)
|
|
278
|
+
properties = schema["properties"]
|
|
279
|
+
return nil unless properties.is_a?(Hash) && properties.key?("edges")
|
|
280
|
+
|
|
281
|
+
edges = resolve(properties["edges"])
|
|
282
|
+
return nil unless array_schema?(edges)
|
|
283
|
+
|
|
284
|
+
items = edges["items"]
|
|
285
|
+
items = resolve(items) if items.is_a?(Hash)
|
|
286
|
+
unless items.is_a?(Hash) && items["properties"].is_a?(Hash) &&
|
|
287
|
+
items["properties"].key?("node")
|
|
288
|
+
return nil
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
items["properties"]["node"]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def pii_comment(schema)
|
|
295
|
+
schema["x-pii"] == true ? "x-pii" : nil
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def record_pii(schema, rel, column, path)
|
|
299
|
+
return unless schema["x-pii"] == true
|
|
300
|
+
|
|
301
|
+
@pii << PiiEntry.new(
|
|
302
|
+
table_rel: rel,
|
|
303
|
+
column: column,
|
|
304
|
+
json_path: path.join("."),
|
|
305
|
+
note: schema["x-pii-note"]
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|