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,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "oj"
5
+
6
+ module StructuredDataToSql
7
+ module Json
8
+ # A JSON value kept verbatim as serialized JSON text (fallback for shapes
9
+ # that do not shred cleanly into columns or child tables).
10
+ JsonValue = Struct.new(:text)
11
+
12
+ # One shredded record: +table_rel+ is the table's path relative to the
13
+ # root table ("" for the root itself), +columns+ maps final column names
14
+ # to raw values, +children+ holds nested ShreddedRows for child tables.
15
+ ShreddedRow =
16
+ Struct.new(:table_rel, :columns, :children, :ordinal, :parent_natural_id)
17
+
18
+ # Allocates deterministic, collision-free table and column names. Built
19
+ # during the inference pass and reused verbatim during the emit pass so
20
+ # both passes agree on every name.
21
+ class NameRegistry
22
+ MAX_NAME_LENGTH = 64
23
+
24
+ attr_reader :collisions
25
+
26
+ def initialize
27
+ @columns = {}
28
+ @column_owner = Hash.new { |hash, key| hash[key] = {} }
29
+ @children = {}
30
+ @child_owner = Hash.new { |hash, key| hash[key] = {} }
31
+ @collisions = 0
32
+ end
33
+
34
+ def column_name(table_rel, raw_path, representation)
35
+ key = [table_rel, representation, raw_path.join("\0")]
36
+ @columns[key] ||= allocate(@column_owner[table_rel], key) do
37
+ base = sanitize_path(raw_path)
38
+ representation == :json ? "#{base}_json" : base
39
+ end
40
+ end
41
+
42
+ def child_rel(table_rel, raw_path)
43
+ key = [table_rel, raw_path.join("\0")]
44
+ @children[key] ||= allocate(@child_owner[:tables], key) do
45
+ rel = sanitize_path(raw_path)
46
+ table_rel.empty? ? rel : "#{table_rel}_#{rel}"
47
+ end
48
+ end
49
+
50
+ def self.sanitize(name)
51
+ sanitized =
52
+ name
53
+ .to_s
54
+ .gsub(/([a-z0-9])([A-Z])/, '\1_\2')
55
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
56
+ .downcase
57
+ .gsub(/[^a-z0-9_]+/, "_")
58
+ .squeeze("_")
59
+ .gsub(/\A_+|_+\z/, "")
60
+ sanitized = "t_#{sanitized}" if sanitized.match?(/\A\d/)
61
+ sanitized.empty? ? "unnamed" : sanitized
62
+ end
63
+
64
+ def self.truncate(name)
65
+ return name if name.length <= MAX_NAME_LENGTH
66
+
67
+ "#{name[0, MAX_NAME_LENGTH - 9]}_#{Digest::SHA1.hexdigest(name)[0, 8]}"
68
+ end
69
+
70
+ private
71
+
72
+ def sanitize_path(raw_path)
73
+ self.class.truncate(
74
+ raw_path.map { |part| self.class.sanitize(part) }.join("_")
75
+ )
76
+ end
77
+
78
+ def allocate(taken, key)
79
+ candidate = self.class.truncate(yield)
80
+ unless taken[candidate].nil? || taken[candidate] == key
81
+ @collisions += 1
82
+ suffix = 2
83
+ suffix += 1 until taken[
84
+ self.class.truncate("#{candidate}_#{suffix}")
85
+ ].nil?
86
+ candidate = self.class.truncate("#{candidate}_#{suffix}")
87
+ end
88
+ taken[candidate] = key
89
+ candidate
90
+ end
91
+ end
92
+
93
+ # Turns one JSON record into a tree of ShreddedRows following the
94
+ # convention-based rules: nested objects flatten into prefixed columns,
95
+ # arrays become child tables, GraphQL edges/node wrappers unwrap, and
96
+ # anything too deep or irregular falls back to a *_json text column.
97
+ class Shredder
98
+ def initialize(
99
+ registry:,
100
+ max_depth: 5,
101
+ graphql_unwrap: true,
102
+ json_column_paths: nil,
103
+ forced_json_paths: nil
104
+ )
105
+ @registry = registry
106
+ @max_depth = max_depth
107
+ @graphql_unwrap = graphql_unwrap
108
+ @json_column_paths = (json_column_paths || []).to_set
109
+ @forced_json_paths = forced_json_paths || Set.new
110
+ @json_fallbacks = 0
111
+ end
112
+
113
+ attr_reader :json_fallbacks
114
+
115
+ def shred(record, ordinal)
116
+ build_row("", record, ordinal, nil)
117
+ end
118
+
119
+ private
120
+
121
+ def build_row(table_rel, record, ordinal, parent_natural_id)
122
+ row = ShreddedRow.new(table_rel, {}, [], ordinal, parent_natural_id)
123
+ case record
124
+ when Hash
125
+ walk(record, [], row, record)
126
+ when Array
127
+ add_json_column(row, ["value"], record)
128
+ else
129
+ row.columns["value"] = record
130
+ end
131
+ row
132
+ end
133
+
134
+ def walk(object, path, row, natural_id_source)
135
+ object.each do |key, value|
136
+ new_path = path + [key]
137
+ if forced_json?(row.table_rel, new_path)
138
+ add_json_column(row, new_path, value)
139
+ next
140
+ end
141
+
142
+ case value
143
+ when Hash
144
+ if @graphql_unwrap && (nodes = unwrap_edges(value))
145
+ handle_array(nodes, new_path, row, natural_id_source)
146
+ elsif new_path.length >= @max_depth
147
+ add_json_column(row, new_path, value)
148
+ else
149
+ walk(value, new_path, row, natural_id_source)
150
+ end
151
+ when Array
152
+ handle_array(value, new_path, row, natural_id_source)
153
+ else
154
+ row.columns[
155
+ @registry.column_name(row.table_rel, new_path, :scalar)
156
+ ] = value
157
+ end
158
+ end
159
+ end
160
+
161
+ def handle_array(array, path, row, natural_id_source)
162
+ return if array.empty?
163
+ return add_json_column(row, path, array) if path.length >= @max_depth
164
+
165
+ hashes, others = array.partition { |element| element.is_a?(Hash) }
166
+ if hashes.any? && others.any? ||
167
+ array.any? { |element| element.is_a?(Array) }
168
+ add_json_column(row, path, array)
169
+ elsif hashes.any?
170
+ rel = @registry.child_rel(row.table_rel, path)
171
+ natural_id = natural_id_of(natural_id_source)
172
+ array.each_with_index do |element, index|
173
+ row.children << build_row(rel, element, index, natural_id)
174
+ end
175
+ else
176
+ rel = @registry.child_rel(row.table_rel, path)
177
+ natural_id = natural_id_of(natural_id_source)
178
+ array.each_with_index do |element, index|
179
+ row.children << ShreddedRow.new(
180
+ rel,
181
+ { "value" => element },
182
+ [],
183
+ index,
184
+ natural_id
185
+ )
186
+ end
187
+ end
188
+ end
189
+
190
+ def add_json_column(row, path, value)
191
+ @json_fallbacks += 1
192
+ row.columns[
193
+ @registry.column_name(row.table_rel, path, :json)
194
+ ] = JsonValue.new(Oj.dump(value, mode: :compat))
195
+ end
196
+
197
+ def forced_json?(table_rel, path)
198
+ return true if @forced_json_paths.include?([table_rel, path.join("\0")])
199
+ return false if @json_column_paths.empty? || !table_rel.empty?
200
+
201
+ @json_column_paths.include?(
202
+ path.map { |part| NameRegistry.sanitize(part) }.join(".")
203
+ )
204
+ end
205
+
206
+ # Unwraps a Relay-style connection object to the array of node values.
207
+ #
208
+ # Tolerates the standard Relay connection shape where sibling keys like
209
+ # +pageInfo+ and +totalCount+ live alongside +edges+, and where each edge
210
+ # may carry a +cursor+ (or any other per-edge keys) in addition to +node+.
211
+ # Only the +node+ value is extracted from each edge; all other edge keys
212
+ # are silently discarded.
213
+ #
214
+ # Returns nil if the object does not look like a connection (no +edges+
215
+ # key, non-array edges, or any edge that is missing a +node+ key).
216
+ def unwrap_edges(value)
217
+ return nil unless value.key?("edges") && value["edges"].is_a?(Array)
218
+ unless value["edges"].all? { |edge|
219
+ edge.is_a?(Hash) && edge.key?("node")
220
+ }
221
+ return nil
222
+ end
223
+
224
+ value["edges"].map { |edge| edge["node"] }
225
+ end
226
+
227
+ def natural_id_of(record)
228
+ id = record["id"]
229
+ return id.to_s if id.is_a?(String) || id.is_a?(Integer)
230
+
231
+ record.each do |key, value|
232
+ next unless value.is_a?(String) || value.is_a?(Integer)
233
+
234
+ sanitized = NameRegistry.sanitize(key)
235
+ return value.to_s if sanitized == "id" || sanitized.end_with?("_id")
236
+ end
237
+ nil
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../sql_text"
5
+
6
+ module StructuredDataToSql
7
+ module Json
8
+ # Writes MySQL/MariaDB DDL and batched INSERT statements following the
9
+ # same conventions as the XML converter output.
10
+ class SqlEmitter
11
+ META_ROOT = ["_sid"].freeze
12
+ META_CHILD = %w[_sid _parent_sid _parent_id _ordinal].freeze
13
+ MAX_INLINE_ROW_BYTES = 60_000
14
+ TEXT_POINTER_BYTES = 20
15
+
16
+ def write_header(out, source)
17
+ out.write("-- Generated by json-to-sql converter\n")
18
+ out.write("-- Source: #{source}\n")
19
+ out.write(
20
+ "-- Generated at: #{Time.now.strftime("%Y-%m-%d %H:%M:%S")}\n\n"
21
+ )
22
+ out.write(
23
+ "SET NAMES utf8mb4;\nSET sql_mode = '';\nSET FOREIGN_KEY_CHECKS = 0;\nSET UNIQUE_CHECKS = 0;\n\n"
24
+ )
25
+ end
26
+
27
+ def write_footer(out)
28
+ out.write("\nSET FOREIGN_KEY_CHECKS = 1;\nSET UNIQUE_CHECKS = 1;\n")
29
+ end
30
+
31
+ def write_create_table(out, name, column_defs, child:)
32
+ column_defs = row_safe_column_defs(column_defs, child:)
33
+ lines = [" `_sid` BIGINT NOT NULL"]
34
+ if child
35
+ lines << " `_parent_sid` BIGINT NOT NULL"
36
+ lines << " `_parent_id` VARCHAR(255)"
37
+ lines << " `_ordinal` BIGINT NOT NULL"
38
+ end
39
+ column_defs.each do |col|
40
+ definition =
41
+ " #{SqlText.escape_identifier(col.name)} #{col.sql_type}"
42
+ definition += " NOT NULL" unless col.null
43
+ definition +=
44
+ " COMMENT #{SqlText.escape_sql_string(col.comment)}" if col.comment
45
+ lines << definition
46
+ end
47
+ lines << " PRIMARY KEY (`_sid`)"
48
+ if child
49
+ lines << " KEY `idx_parent_sid` (`_parent_sid`)"
50
+ lines << " KEY `idx_parent_id` (`_parent_id`)"
51
+ end
52
+ id_column = column_defs.find { |col| col.name == "id" }
53
+ # TEXT-family columns cannot carry a plain KEY without a prefix length.
54
+ if id_column && !id_column.sql_type.end_with?("TEXT")
55
+ lines << " KEY `idx_id` (`id`)"
56
+ end
57
+
58
+ out.write("\n-- Table: #{name}\n")
59
+ out.write("DROP TABLE IF EXISTS #{SqlText.escape_identifier(name)};\n")
60
+ out.write(
61
+ "CREATE TABLE #{SqlText.escape_identifier(name)} (\n#{lines.join(",\n")}\n"
62
+ )
63
+ out.write(
64
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;\n"
65
+ )
66
+ end
67
+
68
+ def write_batch(out, name, columns, value_rows)
69
+ return if value_rows.empty?
70
+
71
+ identifiers = columns.map { |column| SqlText.escape_identifier(column) }
72
+ out.write(
73
+ "INSERT INTO #{SqlText.escape_identifier(name)} (#{identifiers.join(", ")}) VALUES\n"
74
+ )
75
+ out.write(
76
+ value_rows.map { |values| " (#{values.join(", ")})" }.join(",\n")
77
+ )
78
+ out.write(";\n")
79
+ end
80
+
81
+ # PII manifest derived from x-pii annotations in the JSON Schemas, so
82
+ # downstream redaction tooling can query column sensitivity in MariaDB.
83
+ def write_meta_table(out, rows)
84
+ out.write("\n-- Table: _json_meta\n")
85
+ out.write("DROP TABLE IF EXISTS `_json_meta`;\n")
86
+ out.write(<<~SQL)
87
+ CREATE TABLE `_json_meta` (
88
+ `table_name` VARCHAR(64) NOT NULL,
89
+ `column_name` VARCHAR(64) NOT NULL,
90
+ `json_path` VARCHAR(255) NOT NULL,
91
+ `pii` TINYINT(1) NOT NULL DEFAULT 0,
92
+ `pii_note` TEXT,
93
+ `source_schema` VARCHAR(255),
94
+ PRIMARY KEY (`table_name`, `column_name`)
95
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
96
+ SQL
97
+ value_rows =
98
+ rows.map do |table, column, json_path, pii, note, source|
99
+ [
100
+ SqlText.escape_sql_string(table),
101
+ SqlText.escape_sql_string(column),
102
+ SqlText.escape_sql_string(json_path),
103
+ pii.to_s,
104
+ note.nil? ? "NULL" : SqlText.escape_sql_string(note),
105
+ SqlText.escape_sql_string(source)
106
+ ]
107
+ end
108
+ write_batch(
109
+ out,
110
+ "_json_meta",
111
+ %w[table_name column_name json_path pii pii_note source_schema],
112
+ value_rows
113
+ )
114
+ end
115
+
116
+ def format_value(value, column_def)
117
+ return format_null(column_def) if value.nil?
118
+ return SqlText.escape_sql_string(value.text) if value.is_a?(JsonValue)
119
+
120
+ case column_def&.kind
121
+ when :boolean
122
+ value == true ? "1" : "0"
123
+ when :integer, :float
124
+ if value.is_a?(Numeric)
125
+ value.to_s
126
+ else
127
+ SqlText.escape_sql_string(value.to_s)
128
+ end
129
+ when :datetime
130
+ format_datetime(value)
131
+ else
132
+ SqlText.escape_sql_string(value.to_s)
133
+ end
134
+ end
135
+
136
+ private
137
+
138
+ def row_safe_column_defs(column_defs, child:)
139
+ definitions = column_defs.map(&:dup)
140
+ inline_bytes = child ? 8 + 8 + 1_022 + 8 : 8
141
+ inline_bytes +=
142
+ definitions.sum { |column| inline_bytes_for(column.sql_type) }
143
+ return definitions if inline_bytes <= MAX_INLINE_ROW_BYTES
144
+
145
+ candidates =
146
+ definitions
147
+ .select { |column| column.sql_type.match?(/\AVARCHAR\(\d+\)\z/) }
148
+ .sort_by do |column|
149
+ width = inline_bytes_for(column.sql_type)
150
+ [
151
+ column.name == "id" ? 1 : 0,
152
+ column.null ? 0 : 1,
153
+ -width,
154
+ column.name
155
+ ]
156
+ end
157
+ candidates.each do |column|
158
+ break if inline_bytes <= MAX_INLINE_ROW_BYTES
159
+
160
+ inline_bytes -= inline_bytes_for(column.sql_type) - TEXT_POINTER_BYTES
161
+ column.sql_type = "TEXT"
162
+ end
163
+ definitions
164
+ end
165
+
166
+ def inline_bytes_for(sql_type)
167
+ if (match = sql_type.match(/\AVARCHAR\((\d+)\)\z/))
168
+ match[1].to_i * 4 + 2
169
+ elsif sql_type.end_with?("TEXT")
170
+ TEXT_POINTER_BYTES
171
+ elsif sql_type == "BIGINT" || sql_type == "DOUBLE"
172
+ 8
173
+ elsif sql_type == "DATETIME"
174
+ 5
175
+ else
176
+ 1
177
+ end
178
+ end
179
+
180
+ def format_null(column_def)
181
+ return "NULL" if column_def.nil? || column_def.null
182
+
183
+ case column_def.kind
184
+ when :integer, :float, :boolean
185
+ "0"
186
+ else
187
+ "''"
188
+ end
189
+ end
190
+
191
+ def format_datetime(value)
192
+ "'#{Time.iso8601(value.to_s).utc.strftime("%Y-%m-%d %H:%M:%S")}'"
193
+ rescue ArgumentError
194
+ SqlText.escape_sql_string(value.to_s)
195
+ end
196
+ end
197
+ end
198
+ end