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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0ccb8d7b174afed7679305fa1e49d6892bbe7000d3816e01694aad2390773bc3
4
+ data.tar.gz: 522028f6c1f0935a9e58e52730c46c7682863fd3431b2ffd78bca9f74f6a6718
5
+ SHA512:
6
+ metadata.gz: 9c3c997b1c817e2536deb6bdd0a86c945101b95a6d46f37cd8dbd875b3f1010f6f7538fe631edbb999fc56c105a632634fd67dd8fcccb761c3777b8eccd8ea4f
7
+ data.tar.gz: ca3f01ad1ac74c4720e4991f720f377c533076f1d6b54c0d4f2f0e2b3797e67e668e6fff26e249bae28a5c3b5df507b9c9b988fcda39ca8f49fe23644589f8bc
data/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Civilized Discourse Construction Kit, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "structured_data_to_sql/cli"
5
+
6
+ exit StructuredDataToSql::CLI.run(ARGV)
@@ -0,0 +1,405 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../structured_data_to_sql"
5
+
6
+ module StructuredDataToSql
7
+ class CLI
8
+ def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
9
+ new(stdin:, stdout:, stderr:).run(argv)
10
+ end
11
+
12
+ def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr)
13
+ @stdin = stdin
14
+ @stdout = stdout
15
+ @stderr = stderr
16
+ end
17
+
18
+ def run(argv)
19
+ command = argv.shift
20
+ return print_help if command.nil? || %w[-h --help help].include?(command)
21
+ return print_version if %w[-v --version version].include?(command)
22
+
23
+ case command
24
+ when "json"
25
+ convert_json(argv)
26
+ when "xml", "mysql-xml"
27
+ convert_xml(argv)
28
+ else
29
+ raise UsageError,
30
+ "Unknown command #{command.inspect}; expected json or xml"
31
+ end
32
+ 0
33
+ rescue Json::ParseError, MysqlDumpXml::ParseError => e
34
+ @stderr.puts("error: #{e.message}")
35
+ 1
36
+ rescue ConfigurationError, OptionParser::ParseError => e
37
+ @stderr.puts("error: #{e.message}")
38
+ 2
39
+ rescue Error, SystemCallError, IOError, Zlib::Error => e
40
+ @stderr.puts("error: #{e.message}")
41
+ 1
42
+ end
43
+
44
+ private
45
+
46
+ def convert_json(argv)
47
+ options = common_options
48
+ options.merge!(max_depth: JsonConverter::DEFAULT_MAX_DEPTH, ndjson: :auto)
49
+ parser =
50
+ OptionParser.new do |opts|
51
+ opts.banner =
52
+ "Usage: structured-data-to-sql json SOURCE --output FILE [options]"
53
+ add_common_options(opts, options)
54
+ opts.on(
55
+ "--profile NAME",
56
+ "Preparation profile (khoros-api-export)"
57
+ ) { |v| options[:profile] = v }
58
+ opts.on("--records-path PATH", "Dot path containing records") do |v|
59
+ options[:records_path] = v
60
+ end
61
+ opts.on(
62
+ "--records-path-config FILE",
63
+ "Per-file records paths (YAML/JSON)"
64
+ ) { |v| options[:records_path_config] = v }
65
+ opts.on("--table-name NAME", "Override the root table name") do |v|
66
+ options[:table_name] = v
67
+ end
68
+ opts.on(
69
+ "--max-depth N",
70
+ Integer,
71
+ "Maximum nested object depth"
72
+ ) { |v| options[:max_depth] = v }
73
+ opts.on("--schema-dir DIR", "JSON Schema directory") do |v|
74
+ options[:schema_dir] = v
75
+ end
76
+ opts.on("--[no-]meta-table", "Emit JSON path metadata") do |v|
77
+ options[:meta_table] = v
78
+ end
79
+ opts.on("--ndjson", "Treat input as NDJSON") do
80
+ options[:ndjson] = true
81
+ end
82
+ opts.on("--no-ndjson", "Treat input as one JSON document") do
83
+ options[:ndjson] = false
84
+ end
85
+ opts.on(
86
+ "--json-columns LIST",
87
+ Array,
88
+ "Force paths to JSON columns"
89
+ ) { |v| options[:json_columns] = v }
90
+ opts.on("--[no-]graphql-unwrap", "Unwrap GraphQL nodes") do |v|
91
+ options[:graphql_unwrap] = v
92
+ end
93
+ opts.on("--raw-dates", "Do not infer DATETIME columns") do
94
+ options[:raw_dates] = true
95
+ end
96
+ opts.on(
97
+ "--recover-truncated",
98
+ "Recover complete records from truncated input"
99
+ ) { options[:recover_truncated] = true }
100
+ end
101
+ parsed = parse_source_and_output(parser, argv, options)
102
+ return unless parsed
103
+
104
+ source, output = parsed
105
+ handler = progress_handler(options)
106
+ JsonConverter.new(**converter_options(options)).convert(
107
+ source: source,
108
+ output: output,
109
+ file_pattern: options[:pattern] || "*.json",
110
+ atomic: true,
111
+ on_progress: handler,
112
+ **progress_interval_option(handler)
113
+ )
114
+ end
115
+
116
+ def convert_xml(argv)
117
+ return inspect_xml(argv.drop(1)) if argv.first == "inspect"
118
+
119
+ options = common_options
120
+ options[:scrub_invalid_xml_chars] = true
121
+ parser =
122
+ OptionParser.new do |opts|
123
+ opts.banner =
124
+ "Usage: structured-data-to-sql xml SOURCE --output FILE [options]"
125
+ add_common_options(opts, options)
126
+ opts.on(
127
+ "--[no-]scrub-invalid-xml-chars",
128
+ "Repair invalid XML characters"
129
+ ) { |v| options[:scrub_invalid_xml_chars] = v }
130
+ opts.on(
131
+ "--invalid-xml-report FILE",
132
+ "Write invalid-character report"
133
+ ) { |v| options[:invalid_xml_report_path] = v }
134
+ end
135
+ parsed = parse_source_and_output(parser, argv, options)
136
+ return unless parsed
137
+
138
+ source, output = parsed
139
+ handler = progress_handler(options)
140
+ XmlConverter.new(**converter_options(options)).convert(
141
+ source: source,
142
+ output: output,
143
+ file_pattern: options[:pattern] || "*.xml",
144
+ atomic: true,
145
+ on_progress: handler,
146
+ **progress_interval_option(handler)
147
+ )
148
+ end
149
+
150
+ def inspect_xml(argv)
151
+ options = {
152
+ include_files: nil,
153
+ exclude_files: [],
154
+ input_gzip: false,
155
+ verbose: true
156
+ }
157
+ parser =
158
+ OptionParser.new do |opts|
159
+ opts.banner =
160
+ "Usage: structured-data-to-sql xml inspect SOURCE [options]"
161
+ opts.on("--pattern GLOB", "Input filename pattern") do |value|
162
+ options[:pattern] = value
163
+ end
164
+ opts.on(
165
+ "--include-files LIST",
166
+ Array,
167
+ "Only these source files"
168
+ ) { |value| options[:include_files] = value }
169
+ opts.on(
170
+ "--exclude-files LIST",
171
+ Array,
172
+ "Skip these source files"
173
+ ) { |value| options[:exclude_files] = value }
174
+ opts.on("--input-gzip", "Read stdin/IO as gzip") do
175
+ options[:input_gzip] = true
176
+ end
177
+ opts.on("--quiet", "Suppress progress output") do
178
+ options[:verbose] = false
179
+ end
180
+ opts.on_tail("-h", "--help", "Show this help") do
181
+ options[:help] = true
182
+ end
183
+ end
184
+ parser.parse!(argv)
185
+ if options.delete(:help)
186
+ @stdout.puts(parser)
187
+ return
188
+ end
189
+ raise UsageError, parser.banner unless argv.length == 1
190
+
191
+ source_argument = argv.first
192
+ source = source_argument == "-" ? @stdin : source_argument
193
+ discovery =
194
+ Xml::TableDiscovery.new(
195
+ include_files: options[:include_files],
196
+ exclude_files: options[:exclude_files]
197
+ ).discover(
198
+ source,
199
+ file_pattern: options[:pattern] || "*.xml",
200
+ input_gzip: options[:input_gzip],
201
+ progress_callback: xml_inspection_progress_handler(options)
202
+ )
203
+ print_xml_inspection(discovery, source_argument)
204
+ end
205
+
206
+ def print_xml_inspection(discovery, source)
207
+ tables =
208
+ discovery.tables.sort_by do |table|
209
+ metadata = discovery.table_metadata[table] || {}
210
+ [
211
+ -metadata[:xml_data_bytes].to_i,
212
+ -Xml::TableDiscovery.total_size(metadata),
213
+ -metadata[:rows].to_i,
214
+ table
215
+ ]
216
+ end
217
+ table_width = ["TABLE".length, tables.map(&:length).max.to_i].max
218
+
219
+ @stdout.puts("XML table inspection")
220
+ @stdout.puts("Source: #{source}")
221
+ @stdout.puts(
222
+ "Files: #{discovery.files.length} selected, #{discovery.files_skipped} skipped"
223
+ )
224
+ @stdout.puts("Scanned: #{Format.format_size(discovery.bytes_scanned)}")
225
+ @stdout.puts("Tables: #{tables.length}")
226
+ @stdout.puts
227
+ @stdout.puts(
228
+ format(
229
+ "%-#{table_width}s %12s %12s %12s %12s %12s",
230
+ "TABLE",
231
+ "XML DATA",
232
+ "ROWS",
233
+ "DB DATA",
234
+ "DB INDEX",
235
+ "DB TOTAL"
236
+ )
237
+ )
238
+ tables.each do |table|
239
+ metadata = discovery.table_metadata[table] || {}
240
+ @stdout.puts(
241
+ format(
242
+ "%-#{table_width}s %12s %12s %12s %12s %12s",
243
+ table,
244
+ Format.format_size(metadata[:xml_data_bytes].to_i),
245
+ metadata[:rows] || "n/a",
246
+ optional_size(metadata[:data_length]),
247
+ optional_size(metadata[:index_length]),
248
+ database_total_size(metadata)
249
+ )
250
+ )
251
+ end
252
+ @stdout.puts
253
+ @stdout.puts("Convert with exclusions:")
254
+ @stdout.puts(
255
+ " structured-data-to-sql xml SOURCE --exclude-tables TABLE1,TABLE2 --output converted.sql.gz"
256
+ )
257
+ end
258
+
259
+ def optional_size(bytes)
260
+ bytes.nil? ? "n/a" : Format.format_size(bytes)
261
+ end
262
+
263
+ def database_total_size(metadata)
264
+ return "n/a" unless Xml::TableDiscovery.size_metadata?(metadata)
265
+
266
+ Format.format_size(Xml::TableDiscovery.total_size(metadata))
267
+ end
268
+
269
+ def xml_inspection_progress_handler(options)
270
+ return nil unless options[:verbose]
271
+
272
+ lambda do |progress|
273
+ case progress[:event]
274
+ when :file_start
275
+ @stderr.puts("inspecting #{progress[:current_file][:name]}")
276
+ when :bytes
277
+ scanned = progress[:bytes_scanned].to_i
278
+ total = progress[:total_input_bytes].to_i
279
+ elapsed = progress[:elapsed].to_f
280
+ percent =
281
+ total.positive? ? format("%.1f%%", scanned * 100.0 / total) : "n/a"
282
+ rate = elapsed.positive? ? scanned / elapsed : 0
283
+ file = progress[:current_file]&.fetch(:name, nil) || "n/a"
284
+ @stderr.puts(
285
+ "inspection: #{Format.format_size(scanned)}/#{Format.format_size(total)} " \
286
+ "(#{percent}), #{Format.format_size(rate)}/s, #{file}, " \
287
+ "#{progress[:tables].to_i} table(s)"
288
+ )
289
+ when :complete
290
+ @stderr.puts("inspection complete: #{progress[:tables]} table(s)")
291
+ end
292
+ end
293
+ end
294
+
295
+ def common_options
296
+ {
297
+ batch_size: 1000,
298
+ include_files: nil,
299
+ exclude_files: [],
300
+ include_tables: nil,
301
+ exclude_tables: [],
302
+ schema_only: false,
303
+ verbose: true,
304
+ input_gzip: false,
305
+ output_gzip: nil
306
+ }
307
+ end
308
+
309
+ def add_common_options(parser, options)
310
+ parser.on("-o", "--output FILE", "Output .sql or .sql.gz file") do |v|
311
+ options[:output] = v
312
+ end
313
+ parser.on("--pattern GLOB", "Input filename pattern") do |v|
314
+ options[:pattern] = v
315
+ end
316
+ parser.on("--batch-size N", Integer, "Rows per INSERT") do |v|
317
+ options[:batch_size] = v
318
+ end
319
+ parser.on("--include-files LIST", Array, "Only these source files") do |v|
320
+ options[:include_files] = v
321
+ end
322
+ parser.on("--exclude-files LIST", Array, "Skip these source files") do |v|
323
+ options[:exclude_files] = v
324
+ end
325
+ parser.on("--include-tables LIST", Array, "Only these tables") do |v|
326
+ options[:include_tables] = v
327
+ end
328
+ parser.on("--exclude-tables LIST", Array, "Skip these tables") do |v|
329
+ options[:exclude_tables] = v
330
+ end
331
+ parser.on("--schema-only", "Emit DDL without rows") do
332
+ options[:schema_only] = true
333
+ end
334
+ parser.on("--quiet", "Suppress progress output") do
335
+ options[:verbose] = false
336
+ end
337
+ parser.on(
338
+ "--diagnostics-report FILE",
339
+ "Write the Markdown diagnostics report here (default: <output>.diagnostics.md)"
340
+ ) { |v| options[:diagnostics_report] = v }
341
+ parser.on(
342
+ "--no-diagnostics-report",
343
+ "Do not write a diagnostics report"
344
+ ) { options[:diagnostics_report] = false }
345
+ parser.on("--input-gzip", "Read stdin/IO as gzip") do
346
+ options[:input_gzip] = true
347
+ end
348
+ parser.on("--output-gzip", "Write stdout/IO as gzip") do
349
+ options[:output_gzip] = true
350
+ end
351
+ parser.on("--no-output-gzip", "Disable inferred output gzip") do
352
+ options[:output_gzip] = false
353
+ end
354
+ parser.on_tail("-h", "--help", "Show this help") { options[:help] = true }
355
+ end
356
+
357
+ def parse_source_and_output(parser, argv, options)
358
+ parser.parse!(argv)
359
+ if options.delete(:help)
360
+ @stdout.puts(parser)
361
+ return nil
362
+ end
363
+ raise UsageError, parser.banner unless argv.length == 1
364
+ raise UsageError, "--output is required" unless options[:output]
365
+
366
+ source = argv.first == "-" ? @stdin : argv.first
367
+ output = options[:output] == "-" ? @stdout : options[:output]
368
+ [source, output]
369
+ end
370
+
371
+ def converter_options(options)
372
+ options.reject { |key, _| %i[output pattern].include?(key) }
373
+ end
374
+
375
+ # Match the converter's event throttle to the display cadence: payloads
376
+ # are only built when the reporter can show them.
377
+ def progress_interval_option(handler)
378
+ return {} unless handler.respond_to?(:converter_interval)
379
+
380
+ { progress_interval: handler.converter_interval }
381
+ end
382
+
383
+ # Diagnostics go to stderr only, so SQL on stdout stays clean. The
384
+ # reporter consumes the converters' already-throttled event stream; the
385
+ # converter's own interval is matched to the display cadence so no
386
+ # payload is built more often than it can be shown.
387
+ def progress_handler(options)
388
+ return nil unless options[:verbose]
389
+
390
+ ProgressReporter.new(io: @stderr, profile: options[:profile])
391
+ end
392
+
393
+ def print_help
394
+ @stdout.puts(
395
+ "Usage: structured-data-to-sql COMMAND [options]\n\nCommands:\n json Convert JSON/NDJSON to MariaDB SQL\n xml Convert mysqldump --xml to MariaDB SQL\n xml inspect Inspect XML table sizes without converting"
396
+ )
397
+ 0
398
+ end
399
+
400
+ def print_version
401
+ @stdout.puts("structured-data-to-sql #{VERSION}")
402
+ 0
403
+ end
404
+ end
405
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredDataToSql
4
+ module ImmutableValue
5
+ private
6
+
7
+ def deep_freeze(value)
8
+ case value
9
+ when Hash
10
+ value.each do |key, item|
11
+ deep_freeze(key)
12
+ deep_freeze(item)
13
+ end
14
+ when Array
15
+ value.each { |item| deep_freeze(item) }
16
+ end
17
+ value.freeze
18
+ end
19
+ end
20
+
21
+ class ConversionResult
22
+ include ImmutableValue
23
+
24
+ COMMON_KEYS = %i[
25
+ format
26
+ files_processed
27
+ files_skipped
28
+ tables_processed
29
+ tables_skipped
30
+ rows_processed
31
+ child_rows_processed
32
+ bytes_read
33
+ bytes_written
34
+ warnings
35
+ ].freeze
36
+
37
+ def initialize(format:, metrics: {})
38
+ values = metrics.transform_keys(&:to_sym).merge(format: format.to_sym)
39
+ COMMON_KEYS.each { |key| values[key] ||= key == :warnings ? [] : 0 }
40
+ @values = deep_freeze(values.dup)
41
+ freeze
42
+ end
43
+
44
+ def [](key)
45
+ @values[key.to_sym]
46
+ end
47
+
48
+ def fetch(...)
49
+ @values.fetch(...)
50
+ end
51
+
52
+ def to_h
53
+ @values.dup
54
+ end
55
+
56
+ def metrics
57
+ @values.reject { |key, _| COMMON_KEYS.include?(key) }.freeze
58
+ end
59
+
60
+ COMMON_KEYS.each { |key| define_method(key) { @values[key] } }
61
+ end
62
+
63
+ class ProgressEvent
64
+ include ImmutableValue
65
+
66
+ attr_reader :type
67
+
68
+ def initialize(type, attributes = {})
69
+ @type = type.to_sym
70
+ @attributes = deep_freeze(attributes.transform_keys(&:to_sym))
71
+ freeze
72
+ end
73
+
74
+ alias event type
75
+
76
+ def [](key)
77
+ key.to_sym == :event ? type : @attributes[key.to_sym]
78
+ end
79
+
80
+ def to_h
81
+ @attributes.merge(event: type)
82
+ end
83
+
84
+ def method_missing(name, *args)
85
+ return @attributes[name] if args.empty? && @attributes.key?(name)
86
+
87
+ super
88
+ end
89
+
90
+ def respond_to_missing?(name, include_private = false)
91
+ @attributes.key?(name) || super
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "conversion_result"
4
+
5
+ module StructuredDataToSql
6
+ # A structured warning: the one-line +message+ that ConversionResult#warnings
7
+ # has always carried, plus the parts a terminal block or a report needs —
8
+ # a stable +code+, a short +title+, a headline +count+, a +summary+, the
9
+ # complete +items+ list (field names, ids), extra +details+ lines, a
10
+ # suggested +action+, and +sources+ (paths to look at). Nothing here holds
11
+ # member values or per-row payloads.
12
+ class Diagnostic
13
+ include ImmutableValue
14
+
15
+ attr_reader :code,
16
+ :title,
17
+ :count,
18
+ :summary,
19
+ :items,
20
+ :items_label,
21
+ :terminal_items,
22
+ :details,
23
+ :action,
24
+ :sources,
25
+ :message
26
+
27
+ def initialize(
28
+ code:,
29
+ title:,
30
+ message:,
31
+ count: nil,
32
+ summary: nil,
33
+ items: [],
34
+ items_label: nil,
35
+ terminal_items: true,
36
+ details: [],
37
+ action: nil,
38
+ sources: []
39
+ )
40
+ @code = code.to_sym
41
+ @title = title.to_s
42
+ @message = message.to_s
43
+ @count = count
44
+ @summary = summary
45
+ @items = deep_freeze(Array(items).map(&:to_s))
46
+ @items_label = items_label
47
+ @terminal_items = terminal_items ? true : false
48
+ @details = deep_freeze(Array(details).map(&:to_s))
49
+ @action = action
50
+ @sources = deep_freeze(Array(sources).map(&:to_s))
51
+ freeze
52
+ end
53
+
54
+ def self.generic(message)
55
+ new(code: :generic, title: "Warning", message: message)
56
+ end
57
+
58
+ def self.wrap(value)
59
+ value.is_a?(Diagnostic) ? value : generic(value.to_s)
60
+ end
61
+
62
+ def to_s
63
+ message
64
+ end
65
+
66
+ def to_h
67
+ {
68
+ code: code,
69
+ title: title,
70
+ count: count,
71
+ summary: summary,
72
+ items: items,
73
+ items_label: items_label,
74
+ terminal_items: terminal_items,
75
+ details: details,
76
+ action: action,
77
+ sources: sources,
78
+ message: message
79
+ }
80
+ end
81
+ end
82
+ end