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