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,1960 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "../../version"
6
+ require_relative "../../diagnostic"
7
+ require_relative "../../format"
8
+
9
+ module StructuredDataToSql
10
+ module Json
11
+ module Profiles
12
+ # Appends converter-ready Khoros API helper tables without changing the
13
+ # append-only raw tables. An occurrence is identified by its envelope id
14
+ # and every flattened context field; the greatest raw _sid wins as a
15
+ # whole (the exporter contract: append-only, last occurrence wins).
16
+ class KhorosApiExport
17
+ NAME = :khoros_api_export
18
+ # Bumped in lockstep with PROFILE_VERSION in the Khoros converter's
19
+ # KhorosApiExportContentStaging whenever a canonical table's shape or
20
+ # semantics change. Version 2 adds normalized root-message read-only state.
21
+ VERSION = 2
22
+ PREFIX = "discourse_khoros_api"
23
+ METADATA_TABLE = "_structured_data_to_sql_profiles"
24
+ # Explicit storage options on every canonical table so they never
25
+ # inherit database defaults: utf8mb4 VARCHAR(255) keys need DYNAMIC
26
+ # row format (a 1020-byte index fails on COMPACT/REDUNDANT servers).
27
+ TABLE_OPTIONS =
28
+ "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC"
29
+ REQUIRED_SCHEMA = {
30
+ "users" => %w[_sid id data_id data_login],
31
+ "messages" => %w[
32
+ _sid
33
+ id
34
+ data_id
35
+ data_author_id
36
+ data_board_id
37
+ data_body
38
+ data_conversation_id
39
+ data_depth
40
+ data_post_time
41
+ data_read_only
42
+ data_subject
43
+ ],
44
+ "nodes" => %w[_sid id data_depth data_id data_node_type data_title]
45
+ }.freeze
46
+ TABLES =
47
+ %w[
48
+ profiles
49
+ avatars
50
+ users
51
+ nodes
52
+ ranks
53
+ roles
54
+ memberships
55
+ badge_definitions
56
+ badge_grants
57
+ engagement
58
+ floated_messages
59
+ tags
60
+ images
61
+ attachments
62
+ files
63
+ source_messages
64
+ ].map { |name| "#{PREFIX}_#{name}" }.freeze
65
+ USER_STRING_FIELDS = {
66
+ id: %w[data_id],
67
+ login: %w[data_login],
68
+ email: %w[data_email data_email_address],
69
+ sso_id: %w[data_sso_id data_ssoid data_sso_uid],
70
+ rank_id: %w[data_rank_id],
71
+ first_name: %w[data_first_name],
72
+ last_name: %w[data_last_name],
73
+ biography: %w[data_biography],
74
+ location: %w[data_location],
75
+ web_page_url: %w[data_web_page_url data_url],
76
+ language: %w[data_language],
77
+ title: %w[data_title],
78
+ signature: %w[data_signature],
79
+ inline_avatar_url: %w[
80
+ data_avatar_profile
81
+ data_avatar_inline
82
+ data_avatar_message
83
+ ],
84
+ registration_status: %w[data_registration_data_status],
85
+ timezone: %w[data_config_timezone data_timezone],
86
+ profile_privacy: %w[
87
+ data_personal_info_privacy
88
+ data_profile_privacy
89
+ data_profile_properties_personal_info_privacy
90
+ ],
91
+ online_status_privacy: %w[
92
+ data_online_status_privacy
93
+ data_profile_properties_online_status_privacy
94
+ ]
95
+ }.freeze
96
+ USER_SCALAR_FIELDS = {
97
+ registration_time: %w[data_registration_data_registration_time],
98
+ last_visit_time: %w[data_last_visit_time],
99
+ banned: %w[data_banned],
100
+ approved: %w[data_email_verified],
101
+ deleted: %w[data_deleted]
102
+ }.freeze
103
+ USER_CANONICAL_COLUMNS =
104
+ (USER_STRING_FIELDS.values + USER_SCALAR_FIELDS.values)
105
+ .flatten
106
+ .to_set
107
+ .freeze
108
+ USER_DELEGATED_COLUMN_PATTERNS = [
109
+ /\Adata_user_badges(?:_|\z)/,
110
+ /\Adata_registration_data_sso_registration_fields(?:_|\z)/
111
+ ].freeze
112
+ # Envelope, transport, link, and type columns plus v2 presentation
113
+ # fields that carry no member attribute. Retained raw, never warned.
114
+ USER_INTENTIONAL_RAW_ONLY_COLUMNS = %w[
115
+ _sid
116
+ id
117
+ api
118
+ exported_at
119
+ export_reason
120
+ url
121
+ data_href
122
+ data_type
123
+ data_view_href
124
+ data_avatar_favicon
125
+ data_avatar_print
126
+ data_avatar_type
127
+ data_date_pattern
128
+ data_friendly_date_enabled
129
+ data_friendly_date_max_age
130
+ data_mailbox_type
131
+ data_metrics_type
132
+ data_rank_type
133
+ data_registration_data_confirm_email_status
134
+ data_registration_data_type
135
+ ].to_set.freeze
136
+ # Lazy v2 collection stubs ({"query": "SELECT ..."}) and the rank
137
+ # definition denormalized onto the user row (canonical via ranks).
138
+ USER_INTENTIONAL_RAW_ONLY_COLUMN_PATTERNS = [
139
+ /\Adata_[a-z0-9_]+_query\z/,
140
+ /\Adata_rank_(?!id\z)/
141
+ ].freeze
142
+ # Real per-member values the converter does not consume yet; warned
143
+ # once per run so a migration decision stays visible.
144
+ USER_REVIEW_ONLY_COLUMNS = %w[
145
+ data_email_address_privacy
146
+ data_online_status
147
+ data_registration_data_registration_access_level
148
+ data_remember_password
149
+ data_show_user_signatures
150
+ ].to_set.freeze
151
+ USER_REVIEW_ONLY_COLUMN_PATTERNS = [
152
+ /\Adata_metrics_/,
153
+ /\Adata_notes(?:_|\z)/,
154
+ /\Adata_c_/
155
+ ].freeze
156
+
157
+ # Validates the converter-facing key contract while records are already
158
+ # streaming through the JSON emitter, so an atomic conversion fails
159
+ # before publishing an unusable dump. Each raw identity (envelope id
160
+ # plus every flattened context field) keeps only its greatest-_sid
161
+ # outcome: an identity digest, the winning canonical id, and any
162
+ # violation message (users add five richness flags). Memory therefore
163
+ # scales with distinct identities at a few dozen bytes each — for
164
+ # identity-dense collections such as messages that is per raw row, so
165
+ # a multi-million-row export costs hundreds of megabytes, not
166
+ # gigabytes.
167
+ class InputValidator
168
+ MAX_KEY_BYTES = 255
169
+ USER_EMAIL_FIELDS = USER_STRING_FIELDS.fetch(:email)
170
+ USER_BANNED_FIELD = USER_SCALAR_FIELDS.fetch(:banned).first
171
+ # Exporter quality signals (contract §9.7): rows that explain what
172
+ # is knowingly missing. Tallied by the first present label field and
173
+ # surfaced as warnings so an empty helper reads as "withheld", not
174
+ # "none".
175
+ QUALITY_SIGNAL_TABLES = {
176
+ "errors" => %w[collection phase],
177
+ "gaps" => %w[collection phase]
178
+ }.freeze
179
+ DIRECT = {
180
+ "avatars" => {
181
+ key: %w[id],
182
+ indexed: {
183
+ user_id: %w[id]
184
+ }
185
+ },
186
+ "users" => {
187
+ key: %w[data_id],
188
+ indexed: {
189
+ id: %w[data_id],
190
+ email: %w[data_email data_email_address],
191
+ rank_id: %w[data_rank_id]
192
+ }
193
+ },
194
+ "nodes" => {
195
+ key: %w[data_id],
196
+ indexed: {
197
+ id: %w[data_id],
198
+ parent_id: %w[data_parent_id]
199
+ }
200
+ },
201
+ "ranks" => {
202
+ key: %w[data_id],
203
+ indexed: {
204
+ id: %w[data_id]
205
+ }
206
+ },
207
+ "roles" => {
208
+ key: %w[data_id],
209
+ indexed: {
210
+ id: %w[data_id],
211
+ node_id: %w[data_node_id]
212
+ }
213
+ },
214
+ "messages" => {
215
+ key: %w[data_id],
216
+ indexed: {
217
+ id: %w[data_id],
218
+ conversation_id: %w[data_conversation_id],
219
+ board_id: %w[data_board_id]
220
+ }
221
+ }
222
+ }.freeze
223
+ OPTIONAL = {
224
+ "user_profiles" => {
225
+ required: {
226
+ user_id: %w[id]
227
+ },
228
+ indexed: {
229
+ user_id: %w[id]
230
+ }
231
+ },
232
+ "user_profiles_data_profile" => {
233
+ required: {
234
+ profile_name: %w[name],
235
+ profile_value: %w[unnamed]
236
+ },
237
+ indexed: {
238
+ profile_name: %w[name]
239
+ },
240
+ child_of: "user_profiles",
241
+ normalized_profile_key: true
242
+ },
243
+ "role_users" => {
244
+ required: {
245
+ container_id: %w[context_role_id],
246
+ user_id: %w[id]
247
+ },
248
+ indexed: {
249
+ container_id: %w[context_role_id],
250
+ user_id: %w[id]
251
+ }
252
+ },
253
+ "grouphub_members" => {
254
+ required: {
255
+ container_id: %w[context_grouphub_id],
256
+ user_id: %w[id]
257
+ },
258
+ indexed: {
259
+ container_id: %w[context_grouphub_id],
260
+ user_id: %w[id]
261
+ }
262
+ },
263
+ "badges" => {
264
+ required: {
265
+ badge_id: %w[data_id_unnamed data_id]
266
+ },
267
+ indexed: {
268
+ badge_id: %w[data_id_unnamed data_id]
269
+ }
270
+ },
271
+ "user_badges" => {
272
+ required: {
273
+ user_id: %w[context_user_id],
274
+ badge_id: %w[
275
+ data_badge_id_unnamed
276
+ data_badge_id
277
+ data_id_unnamed
278
+ data_id
279
+ ]
280
+ },
281
+ indexed: {
282
+ user_id: %w[context_user_id],
283
+ badge_id: %w[
284
+ data_badge_id_unnamed
285
+ data_badge_id
286
+ data_id_unnamed
287
+ data_id
288
+ ]
289
+ }
290
+ },
291
+ "kudos" => {
292
+ required: {
293
+ message_id: %w[context_message_id],
294
+ engagement_id: %w[id]
295
+ },
296
+ indexed: {
297
+ message_id: %w[context_message_id],
298
+ engagement_id: %w[id],
299
+ user_id: %w[data_user_id]
300
+ }
301
+ },
302
+ "floated_messages" => {
303
+ required: {
304
+ board_id: %w[context_board_id],
305
+ message_id: %w[data_message_id id]
306
+ },
307
+ indexed: {
308
+ board_id: %w[context_board_id],
309
+ message_id: %w[data_message_id id]
310
+ }
311
+ },
312
+ "labels" => {
313
+ required: {
314
+ source_id: %w[id],
315
+ message_id: %w[context_message_id]
316
+ },
317
+ indexed: {
318
+ source_id: %w[id],
319
+ message_id: %w[context_message_id]
320
+ }
321
+ },
322
+ "tags" => {
323
+ required: {
324
+ source_id: %w[id],
325
+ message_id: %w[context_message_id]
326
+ },
327
+ indexed: {
328
+ source_id: %w[id],
329
+ message_id: %w[context_message_id]
330
+ }
331
+ },
332
+ "images" => {
333
+ required: {
334
+ message_id: %w[context_message_id],
335
+ image_id: %w[id]
336
+ },
337
+ indexed: {
338
+ message_id: %w[context_message_id],
339
+ image_id: %w[id]
340
+ }
341
+ },
342
+ "uploaded_images" => {
343
+ required: {
344
+ message_id: %w[context_message_id],
345
+ image_id: %w[data_id_unnamed data_id]
346
+ },
347
+ indexed: {
348
+ message_id: %w[context_message_id],
349
+ image_id: %w[data_id_unnamed data_id]
350
+ }
351
+ },
352
+ "attachments" => {
353
+ required: {
354
+ message_id: %w[context_message_id],
355
+ attachment_id: %w[data_id id]
356
+ },
357
+ indexed: {
358
+ message_id: %w[context_message_id],
359
+ attachment_id: %w[data_id id]
360
+ }
361
+ },
362
+ "files" => {
363
+ required: {
364
+ url: %w[url id]
365
+ },
366
+ indexed: {
367
+ source_collection: %w[data_source_collection],
368
+ source_message_id: %w[data_source_message_id]
369
+ }
370
+ },
371
+ "users_data_user_badges_items" => {
372
+ required: {
373
+ badge_id: %w[badge_id]
374
+ },
375
+ indexed: {
376
+ badge_id: %w[badge_id]
377
+ },
378
+ child_of: "users"
379
+ }
380
+ }.freeze
381
+
382
+ # +warnings+ are the one-line strings; +diagnostics+ carry the same
383
+ # facts structured (lists, run scoping, actions) for the terminal
384
+ # blocks and the diagnostics report. One diagnostic per warning.
385
+ attr_reader :warnings, :diagnostics
386
+ # Optional run scoping: { manifest: Json::ExporterManifest or nil,
387
+ # source_paths: { "errors" => path, "gaps" => path } }.
388
+ attr_accessor :run_context
389
+
390
+ MAX_LISTED_IDS = 5000
391
+
392
+ def initialize
393
+ @winners = Hash.new { |hash, table| hash[table] = {} }
394
+ @child_issues = Hash.new { |hash, table| hash[table] = [] }
395
+ @warnings = []
396
+ @diagnostics = []
397
+ @run_context = nil
398
+ @observed_user_columns = Set.new
399
+ @user_richness = {}
400
+ @quality_signals =
401
+ Hash.new { |hash, table| hash[table] = Hash.new(0) }
402
+ @quality_signal_rows = Hash.new { |hash, table| hash[table] = [] }
403
+ @validated = false
404
+ end
405
+
406
+ # +context_columns+ must be pre-sorted; the emitter computes the
407
+ # sorted list once per table definition.
408
+ def observe(table, columns, sid, parent_sid: nil, context_columns: [])
409
+ if QUALITY_SIGNAL_TABLES.key?(table)
410
+ tally_quality_signal(table, columns)
411
+ end
412
+ spec = DIRECT[table] || OPTIONAL[table]
413
+ return if spec.nil?
414
+ if spec.key?(:child_of)
415
+ observe_child(table, spec, columns, sid, parent_sid)
416
+ return
417
+ end
418
+
419
+ winners = @winners[table]
420
+ identity = identity_digest(columns, context_columns)
421
+ if table == "users"
422
+ @observed_user_columns.merge(columns.keys)
423
+ observe_user_richness(identity, columns, sid)
424
+ end
425
+ previous = winners[identity]
426
+ return if previous && previous[0] > sid
427
+
428
+ winners[identity] = if DIRECT.key?(table)
429
+ examine_direct(table, spec, columns, sid)
430
+ else
431
+ examine_optional(table, spec, columns, sid)
432
+ end
433
+ end
434
+
435
+ def validate!
436
+ return if @validated
437
+
438
+ DIRECT.each_key { |table| validate_direct(table) }
439
+ OPTIONAL.each { |table, spec| validate_optional(table, spec) }
440
+ validate_user_columns
441
+ warn_credential_degraded_users
442
+ report_quality_signals
443
+ @validated = true
444
+ end
445
+
446
+ private
447
+
448
+ # Winner tuples are [sid, canonical_id, error] for DIRECT tables and
449
+ # [sid, :omitted or nil, error] for OPTIONAL tables. Every direct
450
+ # collection is a complete observation: the last occurrence wins as
451
+ # a whole, including blank, false, and zero values. Violations are
452
+ # examined while the row is in hand but raised only in validate!, so
453
+ # a superseding occurrence can still repair an earlier bad row.
454
+ def examine_direct(table, spec, columns, sid)
455
+ key_value = first_value(columns, spec[:key])
456
+ if blank?(key_value)
457
+ return [
458
+ sid,
459
+ nil,
460
+ "Khoros API profile requires #{table}.#{spec[:key].first} for the row at #{table}._sid=#{sid}"
461
+ ]
462
+ end
463
+
464
+ [
465
+ sid,
466
+ key_value.to_s,
467
+ oversized_key_error(table, spec[:indexed], columns, sid)
468
+ ]
469
+ end
470
+
471
+ def examine_optional(table, spec, columns, sid)
472
+ missing =
473
+ spec[:required].any? do |_label, fields|
474
+ blank?(first_value(columns, fields))
475
+ end
476
+ return sid, :omitted, nil if missing
477
+
478
+ error =
479
+ oversized_key_error(table, spec.fetch(:indexed, {}), columns, sid)
480
+ [sid, nil, error]
481
+ end
482
+
483
+ def oversized_key_error(table, fields_by_label, columns, sid)
484
+ fields_by_label.each do |label, fields|
485
+ value = first_value(columns, fields)
486
+ next if blank?(value)
487
+ next if value.to_s.bytesize <= MAX_KEY_BYTES
488
+
489
+ return(
490
+ "Khoros API profile key #{table}.#{label} exceeds #{MAX_KEY_BYTES} bytes " \
491
+ "for the row at #{table}._sid=#{sid}: #{value.inspect}"
492
+ )
493
+ end
494
+ nil
495
+ end
496
+
497
+ def validate_direct(table)
498
+ projections = {}
499
+ @winners[table].each_value do |sid, canonical_id, error|
500
+ raise UsageError, error if error
501
+
502
+ if (other_sid = projections[canonical_id])
503
+ raise UsageError,
504
+ "Khoros API profile cannot map distinct #{table} identities " \
505
+ "(rows #{table}._sid=#{other_sid} and #{table}._sid=#{sid}) " \
506
+ "to the same canonical id #{canonical_id.inspect}. Re-export or repair #{table}."
507
+ end
508
+ projections[canonical_id] = sid
509
+ end
510
+ end
511
+
512
+ def validate_optional(table, spec)
513
+ return validate_child(table, spec) if spec[:child_of]
514
+
515
+ omitted = 0
516
+ @winners[table].each_value do |_sid, outcome, error|
517
+ raise UsageError, error if error
518
+
519
+ omitted += 1 if outcome == :omitted
520
+ end
521
+ return if omitted.zero?
522
+
523
+ warn_omitted(table, omitted)
524
+ end
525
+
526
+ def observe_child(table, spec, columns, sid, parent_sid)
527
+ identity = [parent_sid, sid]
528
+ missing =
529
+ spec[:required].find do |_label, fields|
530
+ blank?(first_value(columns, fields))
531
+ end
532
+ if missing
533
+ @child_issues[table] << { parent_sid:, kind: :missing, identity: }
534
+ return
535
+ end
536
+
537
+ spec
538
+ .fetch(:indexed, {})
539
+ .each do |label, fields|
540
+ value = first_value(columns, fields)
541
+ if spec[:normalized_profile_key] && label == :profile_name
542
+ value = normalized_profile_key(value)
543
+ end
544
+ next if value.to_s.bytesize <= MAX_KEY_BYTES
545
+
546
+ @child_issues[table] << {
547
+ parent_sid:,
548
+ kind: :oversized,
549
+ label:,
550
+ value:,
551
+ identity:
552
+ }
553
+ end
554
+ end
555
+
556
+ def validate_child(table, spec)
557
+ winning_parent_sids =
558
+ @winners[spec[:child_of]].values.map(&:first).to_set
559
+ issues =
560
+ @child_issues[table].select do |issue|
561
+ winning_parent_sids.include?(issue[:parent_sid])
562
+ end
563
+ if (
564
+ issue =
565
+ issues.find { |candidate| candidate[:kind] == :oversized }
566
+ )
567
+ raise UsageError,
568
+ "Khoros API profile key #{table}.#{issue[:label]} exceeds #{MAX_KEY_BYTES} bytes " \
569
+ "for identity #{issue[:identity].inspect}: #{issue[:value].inspect}"
570
+ end
571
+
572
+ omitted = issues.count { |candidate| candidate[:kind] == :missing }
573
+ return if omitted.zero?
574
+
575
+ warn_omitted(table, omitted)
576
+ end
577
+
578
+ def first_value(columns, fields)
579
+ fields.each do |field|
580
+ value = columns[field]
581
+ return value unless value.nil?
582
+ end
583
+ nil
584
+ end
585
+
586
+ # Credential-degradation detection. The exporter contract is
587
+ # whole-occurrence last-wins, so a final occurrence written by a
588
+ # weaker credential (blank email, no ban state) silently replaces a
589
+ # richer earlier one. The profile does not compensate — the exporter
590
+ # re-emits such users — but it must not stay quiet about it either.
591
+ def observe_user_richness(identity, columns, sid)
592
+ email_blank = blank?(first_value(columns, USER_EMAIL_FIELDS))
593
+ banned_missing = columns[USER_BANNED_FIELD].nil?
594
+ state =
595
+ @user_richness[identity] ||= [0, false, false, false, false, nil]
596
+ state[1] ||= !email_blank
597
+ state[2] ||= !banned_missing
598
+ return if sid < state[0]
599
+
600
+ state[0] = sid
601
+ state[3] = email_blank
602
+ state[4] = banned_missing
603
+ state[5] = first_value(columns, DIRECT.fetch("users")[:key]).to_s
604
+ end
605
+
606
+ def warn_credential_degraded_users
607
+ degraded_ids =
608
+ @user_richness
609
+ .each_value
610
+ .filter_map do |_sid, had_email, had_banned, email_blank, banned_missing, id|
611
+ if (had_email && email_blank) || (had_banned && banned_missing)
612
+ id
613
+ end
614
+ end
615
+ return if degraded_ids.empty?
616
+
617
+ degraded = degraded_ids.length
618
+ add_diagnostic(
619
+ code: :users_credential_degraded,
620
+ title: "Users — credential degradation",
621
+ count: degraded,
622
+ summary:
623
+ "The winning (last) occurrence of #{Format.format_count(degraded)} user(s) has a blank email " \
624
+ "or no banned state although an earlier occurrence carried it. Last occurrence wins as-is; " \
625
+ "the canonical rows for these users are missing those values.",
626
+ items: degraded_ids.sort.first(MAX_LISTED_IDS),
627
+ items_label: "Affected user ids",
628
+ terminal_items: false,
629
+ details:
630
+ (
631
+ if degraded > MAX_LISTED_IDS
632
+ [
633
+ "#{Format.format_count(degraded - MAX_LISTED_IDS)} more ids not listed; query raw users for winners with blank data_email"
634
+ ]
635
+ else
636
+ []
637
+ end
638
+ ),
639
+ action:
640
+ "Re-export these users with a privileged (administrator) credential; the repaired rows append to users.ndjson and last-wins picks them up on the next conversion.",
641
+ message:
642
+ "users: #{degraded} winning occurrence(s) look credential-degraded " \
643
+ "(blank email or missing banned state while an earlier occurrence carried it); " \
644
+ "last occurrence wins as-is — re-export those users with a privileged credential"
645
+ )
646
+ end
647
+
648
+ def tally_quality_signal(table, columns)
649
+ fields = QUALITY_SIGNAL_TABLES.fetch(table)
650
+ label = first_value(columns, fields)
651
+ label = "unspecified" if blank?(label)
652
+ label = label.to_s
653
+ @quality_signals[table][label] += 1
654
+ @quality_signal_rows[table] << label
655
+ end
656
+
657
+ QUALITY_SIGNAL_TITLES = {
658
+ "errors" => ["Exporter errors", "exporter error row(s)"],
659
+ "gaps" => ["Withheld content", "withheld-content row(s)"]
660
+ }.freeze
661
+
662
+ def report_quality_signals
663
+ QUALITY_SIGNAL_TABLES.each_key do |table|
664
+ tallies = @quality_signals[table]
665
+ next if tallies.empty?
666
+
667
+ total = tallies.values.sum
668
+ breakdown = breakdown_text(tallies)
669
+ title, noun = QUALITY_SIGNAL_TITLES.fetch(table)
670
+ scoped = run_scoped_signal(table, total)
671
+ add_diagnostic(
672
+ code: :"exporter_#{table}",
673
+ title: title,
674
+ count: total,
675
+ summary:
676
+ (
677
+ if table == "errors"
678
+ "Rows in errors.ndjson: exporter requests that failed (a per-user detail fetch, a page, or a run). What they name is knowingly missing from this dump."
679
+ else
680
+ "Rows in gaps.ndjson: content the community counts but would not serve, or capabilities a run lacked. An empty canonical helper may be \"withheld\", not \"none\"."
681
+ end
682
+ ),
683
+ details:
684
+ scoped +
685
+ [
686
+ "History (all runs): #{Format.format_count(total)} — #{breakdown}"
687
+ ],
688
+ action:
689
+ (
690
+ if table == "errors"
691
+ "Rerun the exporter with a privileged credential; retried rows append to the same files and last-wins heals them. Inspect individual rows in errors.ndjson."
692
+ else
693
+ "Read the gap text in gaps.ndjson; capability gaps mean a run was made as an ordinary member."
694
+ end
695
+ ),
696
+ sources: [source_path_for(table)].compact,
697
+ message: "#{table}: #{total} #{noun} — #{breakdown}"
698
+ )
699
+ end
700
+ end
701
+
702
+ # Attributes the last N rows of the append-only file to the latest
703
+ # completed manifest run when the manifest's per-run counts fit
704
+ # inside the file; otherwise says why lifetime totals are shown.
705
+ def run_scoped_signal(table, total)
706
+ manifest = @run_context&.dig(:manifest)
707
+ if manifest.nil?
708
+ return [
709
+ "Latest run: not scoped — no manifest.json beside the export; showing lifetime totals"
710
+ ]
711
+ end
712
+ if manifest.problem
713
+ return [
714
+ "Latest run: not scoped — #{manifest.problem}; showing lifetime totals"
715
+ ]
716
+ end
717
+ latest = manifest.latest_completed
718
+ if latest.nil?
719
+ return [
720
+ "Latest run: not scoped — no completed run in manifest.json; showing lifetime totals"
721
+ ]
722
+ end
723
+
724
+ counter = table == "errors" ? :errors : :gaps
725
+ declared = manifest.runs.sum(&counter)
726
+ if declared > total
727
+ return [
728
+ "Latest run: not scoped — manifest declares #{Format.format_count(declared)} row(s) but the file holds #{Format.format_count(total)}; showing lifetime totals"
729
+ ]
730
+ end
731
+
732
+ latest_count = latest.public_send(counter)
733
+ latest_rows = @quality_signal_rows[table].last(latest_count)
734
+ latest_tallies = Hash.new(0)
735
+ latest_rows.each { |label| latest_tallies[label] += 1 }
736
+ window = [latest.started_at, latest.finished_at].compact.join(" → ")
737
+ lines = [
738
+ "Latest completed run ##{latest.index}#{window.empty? ? "" : " (#{window})"}: " \
739
+ "#{Format.format_count(latest_count)}#{latest_count.positive? ? " — #{breakdown_text(latest_tallies)}" : ""}"
740
+ ]
741
+ unattributed = total - declared
742
+ if unattributed.positive?
743
+ lines << "Unattributed: #{Format.format_count(unattributed)} row(s) written by runs that did not finalize their manifest entry"
744
+ end
745
+ lines
746
+ end
747
+
748
+ def breakdown_text(tallies)
749
+ tallies
750
+ .sort_by { |label, count| [-count, label] }
751
+ .map { |label, count| "#{label} #{count}" }
752
+ .join(", ")
753
+ end
754
+
755
+ def source_path_for(table)
756
+ @run_context&.dig(:source_paths, table)&.to_s
757
+ end
758
+
759
+ def warn_omitted(table, omitted)
760
+ add_diagnostic(
761
+ code: :canonical_rows_omitted,
762
+ title: "#{table} — rows omitted from canonical helpers",
763
+ count: omitted,
764
+ summary:
765
+ "#{Format.format_count(omitted)} winning row(s) of #{table} lack a required key component (blank id or context) and were left out of the canonical helpers. They remain in the raw table.",
766
+ action:
767
+ "Inspect the raw #{table} rows; if the exporter should have supplied the key, re-export that collection.",
768
+ message:
769
+ "#{table}: omitted #{omitted} winning row(s) from canonical helpers because a required key component was blank"
770
+ )
771
+ end
772
+
773
+ def add_diagnostic(**attributes)
774
+ diagnostic = Diagnostic.new(**attributes)
775
+ @warnings << diagnostic.message
776
+ @diagnostics << diagnostic
777
+ diagnostic
778
+ end
779
+
780
+ def validate_user_columns
781
+ review_only = []
782
+ unclassified = []
783
+ @observed_user_columns.sort.each do |column|
784
+ next if KhorosApiExport::USER_CANONICAL_COLUMNS.include?(column)
785
+ if KhorosApiExport::USER_INTENTIONAL_RAW_ONLY_COLUMNS.include?(
786
+ column
787
+ )
788
+ next
789
+ end
790
+ next if column.start_with?("context_")
791
+ if match_any?(
792
+ KhorosApiExport::USER_INTENTIONAL_RAW_ONLY_COLUMN_PATTERNS,
793
+ column
794
+ )
795
+ next
796
+ end
797
+ if match_any?(
798
+ KhorosApiExport::USER_DELEGATED_COLUMN_PATTERNS,
799
+ column
800
+ )
801
+ next
802
+ end
803
+
804
+ if KhorosApiExport::USER_REVIEW_ONLY_COLUMNS.include?(column) ||
805
+ match_any?(
806
+ KhorosApiExport::USER_REVIEW_ONLY_COLUMN_PATTERNS,
807
+ column
808
+ )
809
+ review_only << column
810
+ else
811
+ unclassified << column
812
+ end
813
+ end
814
+ if review_only.any?
815
+ add_diagnostic(
816
+ code: :users_review_only_fields,
817
+ title: "Users — review-only fields",
818
+ count: review_only.length,
819
+ summary:
820
+ "Per-member values the converter does not consume yet. Retained in the raw users table; not mapped to discourse_khoros_api_users.",
821
+ items: review_only,
822
+ items_label: "Fields",
823
+ action:
824
+ "None unless these fields need a migration mapping; then add them to the canonical users contract (profile version bump).",
825
+ message:
826
+ "users: review-only scalar field(s) retained only in the raw table: #{review_only.join(", ")}"
827
+ )
828
+ end
829
+ if unclassified.any?
830
+ add_diagnostic(
831
+ code: :users_unclassified_fields,
832
+ title: "Users — unclassified fields",
833
+ count: unclassified.length,
834
+ summary:
835
+ "Source fields this profile has never seen. Retained in the raw users table only; nothing canonical reads them.",
836
+ items: unclassified,
837
+ items_label: "Fields",
838
+ action:
839
+ "Classify each field in the profile (canonical alias, intentional raw-only, or review-only) so it stops appearing here.",
840
+ message:
841
+ "users: unclassified scalar field(s) retained only in the raw table: #{unclassified.join(", ")}"
842
+ )
843
+ end
844
+ end
845
+
846
+ def match_any?(patterns, column)
847
+ patterns.any? { |pattern| pattern.match?(column) }
848
+ end
849
+
850
+ def blank?(value)
851
+ value.nil? || value.to_s.strip.empty?
852
+ end
853
+
854
+ def normalized_profile_key(value)
855
+ name = value.to_s.strip
856
+ name.include?(".") ? name : "profile.#{name}"
857
+ end
858
+
859
+ # Missing and explicit-null context values share one marker so both
860
+ # collapse to the same identity, matching the SQL PARTITION BY.
861
+ NIL_VALUE = "\u0000"
862
+
863
+ def identity_digest(columns, context_columns)
864
+ parts = [identity_value(columns["id"])]
865
+ context_columns.each do |name|
866
+ parts << name << identity_value(columns[name])
867
+ end
868
+ Digest::MD5.digest(parts.join("\u001F"))
869
+ end
870
+
871
+ def identity_value(value)
872
+ value.nil? ? NIL_VALUE : value.to_s
873
+ end
874
+ end
875
+
876
+ def self.write_preamble(out)
877
+ out.write("DROP TABLE IF EXISTS `#{METADATA_TABLE}`;\n")
878
+ end
879
+
880
+ def initialize(table_columns)
881
+ @table_columns =
882
+ table_columns.transform_values { |value| Array(value).map(&:to_s) }
883
+ end
884
+
885
+ def write(out)
886
+ validate!
887
+ @out = out
888
+ out.write(
889
+ "\n-- structured_data_to_sql profile: khoros_api_export v#{VERSION}\n"
890
+ )
891
+ TABLES.each { |table| statement("DROP TABLE IF EXISTS `#{table}`") }
892
+ create_profiles
893
+ create_avatars
894
+ create_users
895
+ create_nodes
896
+ create_ranks
897
+ create_roles
898
+ create_memberships
899
+ create_badges
900
+ create_engagement
901
+ create_floated_messages
902
+ create_tags
903
+ create_images
904
+ create_attachments
905
+ create_files
906
+ create_source_messages
907
+ create_metadata
908
+ ensure
909
+ @out = nil
910
+ end
911
+
912
+ def validate!
913
+ problems =
914
+ REQUIRED_SCHEMA.filter_map do |table, required|
915
+ next "#{table}=missing" unless table_exists?(table)
916
+
917
+ missing = required - columns(table)
918
+ "#{table}.#{missing.join(", #{table}.")}=missing" if missing.any?
919
+ end
920
+ return if problems.empty?
921
+
922
+ raise UsageError,
923
+ "Unsupported khoros_api_export schema: #{problems.join("; ")}. " \
924
+ "Required collections are users, messages, and nodes; a message_authors-only export is not supported. " \
925
+ "Regenerate the API export with the current khoros-exporter and prepare it again."
926
+ end
927
+
928
+ private
929
+
930
+ def statement(sql)
931
+ @out.write("#{sql.rstrip};\n")
932
+ end
933
+
934
+ def table_exists?(table)
935
+ @table_columns.key?(table)
936
+ end
937
+
938
+ def columns(table)
939
+ @table_columns.fetch(table, [])
940
+ end
941
+
942
+ def has_column?(table, column)
943
+ columns(table).include?(column)
944
+ end
945
+
946
+ # An optional collection can only contribute canonical rows when it
947
+ # carries the envelope id its occurrence identity and keys are built
948
+ # from; without it the helper table is created empty instead of
949
+ # emitting SQL that references a nonexistent column.
950
+ def canonical_source?(table, *required_columns)
951
+ table_exists?(table) && has_column?(table, "id") &&
952
+ required_columns.all? { |column| has_column?(table, column) }
953
+ end
954
+
955
+ # Returns the column expression, falling back per row (not just when
956
+ # the column is absent from the schema): mixed v1/v2 exports can carry
957
+ # the value in either shape on different rows of the same file.
958
+ def optional(table, column, fallback = nil, table_alias: nil)
959
+ unless has_column?(table, column)
960
+ return fallback || "CAST(NULL AS CHAR(255))"
961
+ end
962
+
963
+ expression =
964
+ table_alias ? "#{table_alias}.`#{column}`" : "`#{column}`"
965
+ fallback ? "COALESCE(#{expression}, #{fallback})" : expression
966
+ end
967
+
968
+ def key(expression)
969
+ "CAST(#{expression} AS CHAR(255)) COLLATE utf8mb4_unicode_ci"
970
+ end
971
+
972
+ def text(expression)
973
+ "CONVERT(#{expression} USING utf8mb4) COLLATE utf8mb4_unicode_ci"
974
+ end
975
+
976
+ def present(*expressions)
977
+ expressions
978
+ .map do |expression|
979
+ "#{expression} IS NOT NULL AND TRIM(CAST(#{expression} AS CHAR)) <> ''"
980
+ end
981
+ .join(" AND ")
982
+ end
983
+
984
+ def nonblank(expression)
985
+ "NULLIF(TRIM(#{expression}), '')"
986
+ end
987
+
988
+ def identity_columns(table, table_alias: "source_row")
989
+ (["id"] + columns(table).grep(/\Acontext_/).sort).map do |column|
990
+ "#{table_alias}.`#{column}`"
991
+ end
992
+ end
993
+
994
+ def canonical_cte(table)
995
+ <<~SQL
996
+ RankedRows AS (
997
+ SELECT source_row.*,
998
+ ROW_NUMBER() OVER (
999
+ PARTITION BY #{identity_columns(table).join(", ")}
1000
+ ORDER BY source_row._sid DESC
1001
+ ) AS occurrence_rn
1002
+ FROM `#{table}` source_row
1003
+ )
1004
+ SQL
1005
+ end
1006
+
1007
+ def winning_source(table, table_alias)
1008
+ <<~SQL.gsub(/\s+/, " ").strip
1009
+ (SELECT ranked_source.*
1010
+ FROM (
1011
+ SELECT source_row.*,
1012
+ ROW_NUMBER() OVER (
1013
+ PARTITION BY #{identity_columns(table).join(", ")}
1014
+ ORDER BY source_row._sid DESC
1015
+ ) AS occurrence_rn
1016
+ FROM `#{table}` source_row
1017
+ ) ranked_source
1018
+ WHERE ranked_source.occurrence_rn = 1) #{table_alias}
1019
+ SQL
1020
+ end
1021
+
1022
+ def create_empty(table, columns_sql, indexes: [])
1023
+ lines = columns_sql + indexes
1024
+ statement(<<~SQL)
1025
+ CREATE TABLE `#{table}` (
1026
+ #{lines.join(",\n ")}
1027
+ ) #{TABLE_OPTIONS}
1028
+ SQL
1029
+ end
1030
+
1031
+ def add_primary_key(table, columns)
1032
+ statement(
1033
+ "ALTER TABLE `#{table}` ADD PRIMARY KEY (#{columns.map { |c| "`#{c}`" }.join(", ")})"
1034
+ )
1035
+ end
1036
+
1037
+ def add_index(table, name, columns)
1038
+ statement(
1039
+ "ALTER TABLE `#{table}` ADD INDEX `#{name}` (#{columns.map { |c| "`#{c}`" }.join(", ")})"
1040
+ )
1041
+ end
1042
+
1043
+ def v1_profile_value_columns
1044
+ return [] unless canonical_source?("user_profiles")
1045
+
1046
+ columns("user_profiles").grep(/\Adata_.+_unnamed\z/)
1047
+ end
1048
+
1049
+ def profile_key_for(column)
1050
+ "profile.#{column.delete_prefix("data_").delete_suffix("_unnamed")}"
1051
+ end
1052
+
1053
+ def create_profiles
1054
+ table = "#{PREFIX}_profiles"
1055
+ value_columns = v1_profile_value_columns
1056
+ sources =
1057
+ value_columns.map do |column|
1058
+ profile_key = profile_key_for(column).gsub("'", "''")
1059
+ <<~SQL.strip
1060
+ SELECT 1 AS source_priority, #{key("p.id")} AS user_id,
1061
+ '#{profile_key}' AS profile_key, p.`#{column}` AS profile_value,
1062
+ p._sid AS source_sid, p._sid AS value_sid
1063
+ FROM #{winning_source("user_profiles", "p")}
1064
+ WHERE #{present("p.id", "p.`#{column}`")}
1065
+ SQL
1066
+ end
1067
+
1068
+ if table_exists?("user_profiles_data_profile") &&
1069
+ canonical_source?("user_profiles") &&
1070
+ %w[_parent_sid name unnamed].all? { |column|
1071
+ has_column?("user_profiles_data_profile", column)
1072
+ }
1073
+ normalized_key = <<~SQL.gsub(/\s+/, " ").strip
1074
+ CASE
1075
+ WHEN INSTR(TRIM(pv.name), '.') > 0 THEN TRIM(pv.name)
1076
+ ELSE CONCAT('profile.', TRIM(pv.name))
1077
+ END
1078
+ SQL
1079
+ sources << <<~SQL.strip
1080
+ SELECT 2 AS source_priority, #{key("p.id")} AS user_id,
1081
+ #{key(normalized_key)} AS profile_key, pv.unnamed AS profile_value,
1082
+ p._sid AS source_sid, pv._sid AS value_sid
1083
+ FROM `user_profiles_data_profile` pv
1084
+ JOIN #{winning_source("user_profiles", "p")} ON p._sid = pv._parent_sid
1085
+ WHERE #{present("p.id", "pv.name", "pv.unnamed")}
1086
+ SQL
1087
+ end
1088
+
1089
+ if sources.empty?
1090
+ return(
1091
+ create_empty(
1092
+ table,
1093
+ [
1094
+ "`user_id` VARCHAR(255) NOT NULL",
1095
+ "`profile_key` VARCHAR(255) NOT NULL",
1096
+ "`profile_value` LONGTEXT NULL",
1097
+ "`source_sid` BIGINT NOT NULL"
1098
+ ],
1099
+ indexes: ["PRIMARY KEY (`user_id`, `profile_key`)"]
1100
+ )
1101
+ )
1102
+ end
1103
+
1104
+ statement(<<~SQL)
1105
+ CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
1106
+ WITH ProfileValues AS (
1107
+ #{sources.join("\nUNION ALL\n")}
1108
+ ), RankedValues AS (
1109
+ SELECT ProfileValues.*,
1110
+ ROW_NUMBER() OVER (
1111
+ PARTITION BY user_id, profile_key
1112
+ ORDER BY source_priority DESC, source_sid DESC, value_sid DESC
1113
+ ) AS value_rn
1114
+ FROM ProfileValues
1115
+ )
1116
+ SELECT user_id, profile_key, profile_value, source_sid
1117
+ FROM RankedValues
1118
+ WHERE value_rn = 1
1119
+ SQL
1120
+ add_primary_key(table, %w[user_id profile_key])
1121
+ end
1122
+
1123
+ def create_avatars
1124
+ table = "#{PREFIX}_avatars"
1125
+ unless canonical_source?("avatars")
1126
+ return(
1127
+ create_empty(
1128
+ table,
1129
+ [
1130
+ "`user_id` VARCHAR(255) NOT NULL",
1131
+ "`url` TEXT NULL",
1132
+ "`source_sid` BIGINT NOT NULL"
1133
+ ],
1134
+ indexes: ["PRIMARY KEY (`user_id`)"]
1135
+ )
1136
+ )
1137
+ end
1138
+
1139
+ url =
1140
+ optional(
1141
+ "avatars",
1142
+ "data_url_unnamed",
1143
+ optional("avatars", "data_url"),
1144
+ table_alias: "a"
1145
+ )
1146
+ statement(<<~SQL)
1147
+ CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
1148
+ WITH #{canonical_cte("avatars")}
1149
+ SELECT #{key("a.id")} AS user_id, #{url} AS url, a._sid AS source_sid
1150
+ FROM RankedRows a
1151
+ WHERE a.occurrence_rn = 1
1152
+ SQL
1153
+ add_primary_key(table, %w[user_id])
1154
+ end
1155
+
1156
+ def profile_lookup(key)
1157
+ key = "profile.#{key}" unless key.include?(".")
1158
+ escaped = key.gsub("'", "''")
1159
+ <<~SQL.gsub(/\s+/, " ").strip
1160
+ (SELECT p.profile_value
1161
+ FROM #{PREFIX}_profiles p
1162
+ WHERE p.user_id = #{key("u.data_id")} AND p.profile_key = '#{escaped}')
1163
+ SQL
1164
+ end
1165
+
1166
+ # Reads a canonical user string from the winning occurrence: the first
1167
+ # nonblank alias present in that row, then the profile fallback. Blank
1168
+ # strings are NULL. Nothing is borrowed from superseded occurrences —
1169
+ # the exporter's last occurrence is the complete observation.
1170
+ def user_value(fields, profile_key: nil)
1171
+ expressions =
1172
+ fields.filter_map do |candidate|
1173
+ nonblank("u.`#{candidate}`") if has_column?("users", candidate)
1174
+ end
1175
+ expressions << nonblank(profile_lookup(profile_key)) if profile_key
1176
+ return "CAST(NULL AS CHAR(255))" if expressions.empty?
1177
+ return expressions.first if expressions.one?
1178
+
1179
+ "COALESCE(#{expressions.join(", ")})"
1180
+ end
1181
+
1182
+ # Same as user_value for scalars (booleans, datetimes): raw values are
1183
+ # kept as-is so explicit false and zero survive; only NULL falls
1184
+ # through to the next alias.
1185
+ def user_scalar(fields, null_type:, profile_key: nil)
1186
+ expressions =
1187
+ fields.filter_map do |candidate|
1188
+ "u.`#{candidate}`" if has_column?("users", candidate)
1189
+ end
1190
+ expressions << nonblank(profile_lookup(profile_key)) if profile_key
1191
+ return "CAST(NULL AS #{null_type})" if expressions.empty?
1192
+ return expressions.first if expressions.one?
1193
+
1194
+ "COALESCE(#{expressions.join(", ")})"
1195
+ end
1196
+
1197
+ # Direct booleans arrive as TINYINT while profile fallbacks are text
1198
+ # ('true'/'false'); normalize to 1/0/NULL so one column has one shape.
1199
+ def boolean_value(expression)
1200
+ <<~SQL.gsub(/\s+/, " ").strip
1201
+ CASE
1202
+ WHEN LOWER(TRIM(CAST(#{expression} AS CHAR))) IN ('1', 'true', 'yes', 'y') THEN 1
1203
+ WHEN LOWER(TRIM(CAST(#{expression} AS CHAR))) IN ('0', 'false', 'no', 'n') THEN 0
1204
+ ELSE NULL
1205
+ END
1206
+ SQL
1207
+ end
1208
+
1209
+ def create_users
1210
+ table = "#{PREFIX}_users"
1211
+ s = USER_STRING_FIELDS
1212
+ n = USER_SCALAR_FIELDS
1213
+ avatar = <<~SQL.gsub(/\s+/, " ").strip
1214
+ COALESCE(
1215
+ NULLIF(TRIM(a.url), ''),
1216
+ #{user_value(s.fetch(:inline_avatar_url))}
1217
+ )
1218
+ SQL
1219
+ statement(<<~SQL)
1220
+ CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
1221
+ WITH #{canonical_cte("users")}
1222
+ SELECT
1223
+ u._sid AS source_sid,
1224
+ #{key("u.data_id")} AS id,
1225
+ u.data_login AS login,
1226
+ #{key(user_value(s.fetch(:email)))} AS email,
1227
+ #{user_value(s.fetch(:sso_id))} AS sso_id,
1228
+ #{user_scalar(n.fetch(:registration_time), null_type: "DATETIME")} AS registration_time,
1229
+ #{user_scalar(n.fetch(:last_visit_time), null_type: "DATETIME")} AS last_visit_time,
1230
+ #{key(user_value(s.fetch(:rank_id)))} AS rank_id,
1231
+ #{user_value(s.fetch(:first_name), profile_key: "name_first")} AS first_name,
1232
+ #{user_value(s.fetch(:last_name), profile_key: "name_last")} AS last_name,
1233
+ #{user_value(s.fetch(:biography), profile_key: "biography")} AS biography,
1234
+ #{user_value(s.fetch(:location), profile_key: "location")} AS location,
1235
+ #{user_value(s.fetch(:web_page_url), profile_key: "url_homepage")} AS web_page_url,
1236
+ #{user_value(s.fetch(:language), profile_key: "language")} AS language,
1237
+ #{user_value(s.fetch(:title), profile_key: "title")} AS title,
1238
+ #{user_value(s.fetch(:signature), profile_key: "signature")} AS signature,
1239
+ #{avatar} AS avatar_url,
1240
+ #{user_scalar(n.fetch(:banned), null_type: "SIGNED")} AS banned,
1241
+ #{user_value(s.fetch(:registration_status))} AS registration_status,
1242
+ #{user_value(s.fetch(:timezone), profile_key: "config.timezone")} AS timezone,
1243
+ #{user_value(s.fetch(:profile_privacy), profile_key: "profile.privacy")} AS profile_privacy,
1244
+ #{user_value(s.fetch(:online_status_privacy), profile_key: "profile.show_online_status")} AS online_status_privacy,
1245
+ #{boolean_value(user_scalar(n.fetch(:approved), null_type: "SIGNED", profile_key: "user.email_verified"))} AS approved,
1246
+ COALESCE(#{user_scalar(n.fetch(:deleted), null_type: "SIGNED")}, 0) AS deleted
1247
+ FROM RankedRows u
1248
+ LEFT JOIN #{PREFIX}_avatars a ON a.user_id = #{key("u.data_id")}
1249
+ WHERE u.occurrence_rn = 1
1250
+ SQL
1251
+ add_primary_key(table, %w[id])
1252
+ add_index(table, "idx_email", %w[email])
1253
+ end
1254
+
1255
+ def create_nodes
1256
+ table = "#{PREFIX}_nodes"
1257
+ statement(<<~SQL)
1258
+ CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
1259
+ WITH #{canonical_cte("nodes")}
1260
+ SELECT
1261
+ n._sid AS source_sid,
1262
+ #{key("n.data_id")} AS id,
1263
+ n.data_node_type AS node_type,
1264
+ n.data_title AS title,
1265
+ #{optional("nodes", "data_description", table_alias: "n")} AS description,
1266
+ n.data_depth AS depth,
1267
+ #{optional("nodes", "data_position", "0", table_alias: "n")} AS position,
1268
+ #{optional("nodes", "data_creation_date", table_alias: "n")} AS created_at,
1269
+ #{key(optional("nodes", "data_parent_id", table_alias: "n"))} AS parent_id,
1270
+ COALESCE(#{optional("nodes", "data_hidden", "0", table_alias: "n")}, 0) AS hidden
1271
+ FROM RankedRows n
1272
+ WHERE n.occurrence_rn = 1
1273
+ SQL
1274
+ add_primary_key(table, %w[id])
1275
+ add_index(table, "idx_parent_id", %w[parent_id])
1276
+ end
1277
+
1278
+ def create_ranks
1279
+ table = "#{PREFIX}_ranks"
1280
+ unless canonical_source?("ranks")
1281
+ return(
1282
+ create_empty(
1283
+ table,
1284
+ [
1285
+ "`id` VARCHAR(255) NOT NULL",
1286
+ "`name` VARCHAR(255) NULL",
1287
+ "`position` BIGINT NULL",
1288
+ "`source_sid` BIGINT NOT NULL"
1289
+ ],
1290
+ indexes: ["PRIMARY KEY (`id`)"]
1291
+ )
1292
+ )
1293
+ end
1294
+ statement(<<~SQL)
1295
+ CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
1296
+ WITH #{canonical_cte("ranks")}
1297
+ SELECT #{key("r.data_id")} AS id,
1298
+ #{optional("ranks", "data_name", table_alias: "r")} AS name,
1299
+ #{optional("ranks", "data_position", table_alias: "r")} AS position,
1300
+ r._sid AS source_sid
1301
+ FROM RankedRows r
1302
+ WHERE r.occurrence_rn = 1
1303
+ SQL
1304
+ add_primary_key(table, %w[id])
1305
+ end
1306
+
1307
+ def create_roles
1308
+ table = "#{PREFIX}_roles"
1309
+ unless canonical_source?("roles")
1310
+ return(
1311
+ create_empty(
1312
+ table,
1313
+ [
1314
+ "`id` VARCHAR(255) NOT NULL",
1315
+ "`name` VARCHAR(255) NULL",
1316
+ "`description` TEXT NULL",
1317
+ "`node_id` VARCHAR(255) NULL",
1318
+ "`source_sid` BIGINT NOT NULL"
1319
+ ],
1320
+ indexes: ["PRIMARY KEY (`id`)"]
1321
+ )
1322
+ )
1323
+ end
1324
+ statement(<<~SQL)
1325
+ CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
1326
+ WITH #{canonical_cte("roles")}
1327
+ SELECT #{key("r.data_id")} AS id,
1328
+ #{optional("roles", "data_name", table_alias: "r")} AS name,
1329
+ #{optional("roles", "data_description", table_alias: "r")} AS description,
1330
+ #{key(optional("roles", "data_node_id", table_alias: "r"))} AS node_id,
1331
+ r._sid AS source_sid
1332
+ FROM RankedRows r
1333
+ WHERE r.occurrence_rn = 1
1334
+ SQL
1335
+ add_primary_key(table, %w[id])
1336
+ end
1337
+
1338
+ def create_memberships
1339
+ table = "#{PREFIX}_memberships"
1340
+ sources = []
1341
+ if canonical_source?("role_users", "context_role_id")
1342
+ sources << <<~SQL.strip
1343
+ SELECT 'role' AS membership_type, #{key("ru.context_role_id")} AS container_id,
1344
+ #{key("ru.id")} AS user_id, #{key("NULL")} AS membership_role, ru._sid AS source_sid
1345
+ FROM #{winning_source("role_users", "ru")}
1346
+ WHERE #{present("ru.context_role_id", "ru.id")}
1347
+ SQL
1348
+ end
1349
+ if canonical_source?("grouphub_members", "context_grouphub_id")
1350
+ role =
1351
+ optional("grouphub_members", "context_role", table_alias: "gm")
1352
+ sources << <<~SQL.strip
1353
+ SELECT 'grouphub' AS membership_type, #{key("gm.context_grouphub_id")} AS container_id,
1354
+ #{key("gm.id")} AS user_id, #{text(role)} AS membership_role, gm._sid AS source_sid
1355
+ FROM #{winning_source("grouphub_members", "gm")}
1356
+ WHERE #{present("gm.context_grouphub_id", "gm.id")}
1357
+ SQL
1358
+ end
1359
+ if sources.empty?
1360
+ return(
1361
+ create_empty(
1362
+ table,
1363
+ [
1364
+ "`membership_type` VARCHAR(32) NOT NULL",
1365
+ "`container_id` VARCHAR(255) NOT NULL",
1366
+ "`user_id` VARCHAR(255) NOT NULL",
1367
+ "`membership_role` VARCHAR(255) NULL",
1368
+ "`source_sid` BIGINT NOT NULL"
1369
+ ],
1370
+ indexes: [
1371
+ "PRIMARY KEY (`membership_type`, `container_id`, `user_id`)",
1372
+ "KEY `idx_user_id` (`user_id`)"
1373
+ ]
1374
+ )
1375
+ )
1376
+ end
1377
+ statement(<<~SQL)
1378
+ CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
1379
+ WITH MembershipRows AS (
1380
+ #{sources.join("\nUNION ALL\n")}
1381
+ ), RankedMemberships AS (
1382
+ SELECT MembershipRows.*,
1383
+ ROW_NUMBER() OVER (
1384
+ PARTITION BY membership_type, container_id, user_id
1385
+ ORDER BY source_sid DESC
1386
+ ) AS membership_rn
1387
+ FROM MembershipRows
1388
+ )
1389
+ SELECT membership_type, container_id, user_id, membership_role, source_sid
1390
+ FROM RankedMemberships
1391
+ WHERE membership_rn = 1
1392
+ SQL
1393
+ add_primary_key(table, %w[membership_type container_id user_id])
1394
+ add_index(table, "idx_user_id", %w[user_id])
1395
+ end
1396
+
1397
+ def inline_badge_source
1398
+ return nil unless table_exists?("users_data_user_badges_items")
1399
+
1400
+ <<~SQL.strip
1401
+ SELECT 1 AS source_priority, #{key("u.data_id")} AS user_id, #{key("bg.badge_id")} AS badge_id,
1402
+ #{text(optional("users_data_user_badges_items", "badge_title", table_alias: "bg"))} AS title,
1403
+ #{text(optional("users_data_user_badges_items", "badge_description", table_alias: "bg"))} AS description,
1404
+ #{text(optional("users_data_user_badges_items", "badge_icon_url", table_alias: "bg"))} AS icon_url,
1405
+ #{optional("users_data_user_badges_items", "earned_date", table_alias: "bg")} AS earned_date,
1406
+ u._sid AS source_sid
1407
+ FROM users_data_user_badges_items bg
1408
+ JOIN #{winning_source("users", "u")} ON u._sid = bg._parent_sid
1409
+ WHERE #{present("u.data_id", "bg.badge_id")}
1410
+ SQL
1411
+ end
1412
+
1413
+ def contextual_badge_source
1414
+ return nil unless canonical_source?("user_badges", "context_user_id")
1415
+
1416
+ badge_id =
1417
+ optional(
1418
+ "user_badges",
1419
+ "data_badge_id_unnamed",
1420
+ optional(
1421
+ "user_badges",
1422
+ "data_badge_id",
1423
+ optional(
1424
+ "user_badges",
1425
+ "data_id_unnamed",
1426
+ optional("user_badges", "data_id")
1427
+ )
1428
+ ),
1429
+ table_alias: "ub"
1430
+ )
1431
+ <<~SQL.strip
1432
+ SELECT 2 AS source_priority, #{key("ub.context_user_id")} AS user_id, #{key(badge_id)} AS badge_id,
1433
+ #{key("NULL")} AS title, #{key("NULL")} AS description, #{key("NULL")} AS icon_url,
1434
+ #{optional("user_badges", "data_earned_date_unnamed", optional("user_badges", "data_earned_date"), table_alias: "ub")} AS earned_date,
1435
+ ub._sid AS source_sid
1436
+ FROM #{winning_source("user_badges", "ub")}
1437
+ WHERE #{present("ub.context_user_id", badge_id)}
1438
+ SQL
1439
+ end
1440
+
1441
+ def badge_definition_source
1442
+ return nil unless canonical_source?("badges")
1443
+
1444
+ badge_id =
1445
+ optional(
1446
+ "badges",
1447
+ "data_id_unnamed",
1448
+ optional("badges", "data_id"),
1449
+ table_alias: "b"
1450
+ )
1451
+ <<~SQL.strip
1452
+ SELECT 2 AS priority, #{key(badge_id)} AS badge_id,
1453
+ #{text(optional("badges", "data_title_unnamed", optional("badges", "data_title"), table_alias: "b"))} AS title,
1454
+ #{text(optional("badges", "data_description_unnamed", optional("badges", "data_description"), table_alias: "b"))} AS description,
1455
+ #{text(optional("badges", "data_icon_url_unnamed", optional("badges", "data_icon_url"), table_alias: "b"))} AS icon_url,
1456
+ b._sid AS source_sid
1457
+ FROM #{winning_source("badges", "b")}
1458
+ WHERE #{present(badge_id)}
1459
+ SQL
1460
+ end
1461
+
1462
+ def create_badges
1463
+ definitions = "#{PREFIX}_badge_definitions"
1464
+ grants = "#{PREFIX}_badge_grants"
1465
+ inline = inline_badge_source
1466
+ contextual = contextual_badge_source
1467
+ explicit = badge_definition_source
1468
+ grant_sources = [inline, contextual].compact
1469
+
1470
+ if grant_sources.empty?
1471
+ create_empty(
1472
+ grants,
1473
+ [
1474
+ "`user_id` VARCHAR(255) NOT NULL",
1475
+ "`badge_id` VARCHAR(255) NOT NULL",
1476
+ "`earned_date` DATETIME NULL",
1477
+ "`source_sid` BIGINT NOT NULL"
1478
+ ],
1479
+ indexes: ["PRIMARY KEY (`user_id`, `badge_id`)"]
1480
+ )
1481
+ else
1482
+ statement(<<~SQL)
1483
+ CREATE TABLE `#{grants}` #{TABLE_OPTIONS} AS
1484
+ WITH BadgeGrantRows AS (
1485
+ #{grant_sources.join("\nUNION ALL\n")}
1486
+ ), RankedBadgeGrants AS (
1487
+ SELECT BadgeGrantRows.*,
1488
+ ROW_NUMBER() OVER (
1489
+ PARTITION BY user_id, badge_id
1490
+ -- Prefer an earned timestamp, then the explicit per-user
1491
+ -- v1 observation over an inline v2 observation. _sid is
1492
+ -- the final precedence within the same source.
1493
+ ORDER BY (earned_date IS NOT NULL) DESC, earned_date DESC,
1494
+ source_priority DESC, source_sid DESC
1495
+ ) AS grant_rn
1496
+ FROM BadgeGrantRows
1497
+ WHERE user_id IS NOT NULL AND badge_id IS NOT NULL
1498
+ )
1499
+ SELECT user_id, badge_id, earned_date, source_sid
1500
+ FROM RankedBadgeGrants
1501
+ WHERE grant_rn = 1
1502
+ SQL
1503
+ add_primary_key(grants, %w[user_id badge_id])
1504
+ end
1505
+
1506
+ definition_sources = []
1507
+ definition_sources << explicit if explicit
1508
+ definition_sources << <<~SQL.strip if inline
1509
+ SELECT 1 AS priority, badge_id, title, description, icon_url, source_sid
1510
+ FROM (#{inline}) inline_badges
1511
+ SQL
1512
+ if definition_sources.empty?
1513
+ return(
1514
+ create_empty(
1515
+ definitions,
1516
+ [
1517
+ "`badge_id` VARCHAR(255) NOT NULL",
1518
+ "`title` VARCHAR(255) NULL",
1519
+ "`description` TEXT NULL",
1520
+ "`icon_url` TEXT NULL",
1521
+ "`source_sid` BIGINT NOT NULL"
1522
+ ],
1523
+ indexes: ["PRIMARY KEY (`badge_id`)"]
1524
+ )
1525
+ )
1526
+ end
1527
+ statement(<<~SQL)
1528
+ CREATE TABLE `#{definitions}` #{TABLE_OPTIONS} AS
1529
+ WITH BadgeDefinitionRows AS (
1530
+ #{definition_sources.join("\nUNION ALL\n")}
1531
+ ), RankedBadgeDefinitions AS (
1532
+ SELECT BadgeDefinitionRows.*,
1533
+ ROW_NUMBER() OVER (
1534
+ PARTITION BY badge_id
1535
+ ORDER BY priority DESC, source_sid DESC
1536
+ ) AS definition_rn
1537
+ FROM BadgeDefinitionRows
1538
+ WHERE badge_id IS NOT NULL
1539
+ )
1540
+ SELECT badge_id, title, description, icon_url, source_sid
1541
+ FROM RankedBadgeDefinitions
1542
+ WHERE definition_rn = 1
1543
+ SQL
1544
+ add_primary_key(definitions, %w[badge_id])
1545
+ end
1546
+
1547
+ def create_engagement
1548
+ table = "#{PREFIX}_engagement"
1549
+ unless canonical_source?("kudos", "context_message_id")
1550
+ return(
1551
+ create_empty(
1552
+ table,
1553
+ [
1554
+ "`message_id` VARCHAR(255) NOT NULL",
1555
+ "`engagement_id` VARCHAR(255) NOT NULL",
1556
+ "`user_id` VARCHAR(255) NULL",
1557
+ "`created_at` DATETIME NULL",
1558
+ "`weight` BIGINT NULL",
1559
+ "`source_sid` BIGINT NOT NULL"
1560
+ ],
1561
+ indexes: [
1562
+ "PRIMARY KEY (`message_id`, `engagement_id`)",
1563
+ "KEY `idx_user_id` (`user_id`)"
1564
+ ]
1565
+ )
1566
+ )
1567
+ end
1568
+ statement(<<~SQL)
1569
+ CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
1570
+ WITH #{canonical_cte("kudos")}
1571
+ SELECT #{key("k.context_message_id")} AS message_id, #{key("k.id")} AS engagement_id,
1572
+ #{key(optional("kudos", "data_user_id", table_alias: "k"))} AS user_id,
1573
+ #{optional("kudos", "data_time", table_alias: "k")} AS created_at,
1574
+ #{optional("kudos", "data_weight", "1", table_alias: "k")} AS weight,
1575
+ k._sid AS source_sid
1576
+ FROM RankedRows k
1577
+ WHERE k.occurrence_rn = 1
1578
+ AND #{present("k.context_message_id", "k.id")}
1579
+ SQL
1580
+ add_primary_key(table, %w[message_id engagement_id])
1581
+ add_index(table, "idx_user_id", %w[user_id])
1582
+ end
1583
+
1584
+ def create_floated_messages
1585
+ table = "#{PREFIX}_floated_messages"
1586
+ unless canonical_source?("floated_messages", "context_board_id")
1587
+ return(
1588
+ create_empty(
1589
+ table,
1590
+ [
1591
+ "`board_id` VARCHAR(255) NOT NULL",
1592
+ "`message_id` VARCHAR(255) NOT NULL",
1593
+ "`source_sid` BIGINT NOT NULL"
1594
+ ],
1595
+ indexes: ["PRIMARY KEY (`board_id`, `message_id`)"]
1596
+ )
1597
+ )
1598
+ end
1599
+ message_id =
1600
+ (
1601
+ if has_column?("floated_messages", "data_message_id")
1602
+ "f.data_message_id"
1603
+ else
1604
+ "f.id"
1605
+ end
1606
+ )
1607
+ statement(<<~SQL)
1608
+ CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
1609
+ WITH #{canonical_cte("floated_messages")}
1610
+ SELECT #{key("f.context_board_id")} AS board_id, #{key(message_id)} AS message_id,
1611
+ f._sid AS source_sid
1612
+ FROM RankedRows f
1613
+ WHERE f.occurrence_rn = 1
1614
+ AND #{present("f.context_board_id", message_id)}
1615
+ SQL
1616
+ add_primary_key(table, %w[board_id message_id])
1617
+ end
1618
+
1619
+ def create_tags
1620
+ table = "#{PREFIX}_tags"
1621
+ sources =
1622
+ %w[labels tags].filter_map do |source|
1623
+ next unless canonical_source?(source, "context_message_id")
1624
+
1625
+ text_column =
1626
+ has_column?(source, "data_text") ? "data_text" : "data_name"
1627
+ next unless has_column?(source, text_column)
1628
+
1629
+ <<~SQL.strip
1630
+ SELECT '#{source == "labels" ? "label" : "freeform_tag"}' AS source_type,
1631
+ #{key("t.id")} AS source_id, #{key("t.context_message_id")} AS message_id,
1632
+ t.`#{text_column}` AS text, t._sid AS source_sid
1633
+ FROM #{winning_source(source, "t")}
1634
+ WHERE #{present("t.id", "t.context_message_id")}
1635
+ SQL
1636
+ end
1637
+ if sources.empty?
1638
+ return(
1639
+ create_empty(
1640
+ table,
1641
+ [
1642
+ "`source_type` VARCHAR(32) NOT NULL",
1643
+ "`source_id` VARCHAR(255) NOT NULL",
1644
+ "`message_id` VARCHAR(255) NOT NULL",
1645
+ "`text` VARCHAR(255) NULL",
1646
+ "`source_sid` BIGINT NOT NULL"
1647
+ ],
1648
+ indexes: [
1649
+ "PRIMARY KEY (`source_type`, `source_id`, `message_id`)",
1650
+ "KEY `idx_message_id` (`message_id`)"
1651
+ ]
1652
+ )
1653
+ )
1654
+ end
1655
+ statement(<<~SQL)
1656
+ CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
1657
+ WITH TagRows AS (
1658
+ #{sources.join("\nUNION ALL\n")}
1659
+ ), RankedTags AS (
1660
+ SELECT TagRows.*,
1661
+ ROW_NUMBER() OVER (
1662
+ PARTITION BY source_type, source_id, message_id
1663
+ ORDER BY source_sid DESC
1664
+ ) AS tag_rn
1665
+ FROM TagRows
1666
+ )
1667
+ SELECT source_type, source_id, message_id, text, source_sid
1668
+ FROM RankedTags
1669
+ WHERE tag_rn = 1
1670
+ SQL
1671
+ add_primary_key(table, %w[source_type source_id message_id])
1672
+ add_index(table, "idx_message_id", %w[message_id])
1673
+ end
1674
+
1675
+ def create_images
1676
+ table = "#{PREFIX}_images"
1677
+ sources = []
1678
+ if canonical_source?("images", "context_message_id")
1679
+ sources << <<~SQL.strip
1680
+ SELECT 1 AS priority, #{key("i.context_message_id")} AS message_id, #{key("i.id")} AS image_id,
1681
+ #{text("COALESCE(#{nonblank(optional("images", "data_original_href", table_alias: "i"))}, #{nonblank(optional("images", "data_large_href", table_alias: "i"))}, #{nonblank(optional("images", "data_view_href", table_alias: "i"))})")} AS url,
1682
+ #{text(optional("images", "data_title", table_alias: "i"))} AS title,
1683
+ #{text(optional("images", "data_description", table_alias: "i"))} AS description,
1684
+ i._sid AS source_sid
1685
+ FROM #{winning_source("images", "i")}
1686
+ WHERE #{present("i.context_message_id", "i.id")}
1687
+ SQL
1688
+ end
1689
+ if canonical_source?("uploaded_images", "context_message_id")
1690
+ image_id =
1691
+ optional(
1692
+ "uploaded_images",
1693
+ "data_id_unnamed",
1694
+ optional("uploaded_images", "data_id"),
1695
+ table_alias: "i"
1696
+ )
1697
+ url =
1698
+ optional(
1699
+ "uploaded_images",
1700
+ "data_url_unnamed",
1701
+ optional("uploaded_images", "data_url"),
1702
+ table_alias: "i"
1703
+ )
1704
+ title =
1705
+ optional(
1706
+ "uploaded_images",
1707
+ "data_title_unnamed",
1708
+ optional("uploaded_images", "data_title"),
1709
+ table_alias: "i"
1710
+ )
1711
+ description =
1712
+ optional(
1713
+ "uploaded_images",
1714
+ "data_description_unnamed",
1715
+ optional("uploaded_images", "data_description"),
1716
+ table_alias: "i"
1717
+ )
1718
+ usable_url = "CASE WHEN #{present(url)} THEN #{url} ELSE NULL END"
1719
+ sources << <<~SQL.strip
1720
+ SELECT 2 AS priority, #{key("i.context_message_id")} AS message_id, #{key(image_id)} AS image_id,
1721
+ #{text(usable_url)} AS url, #{text(title)} AS title,
1722
+ #{text(description)} AS description, i._sid AS source_sid
1723
+ FROM #{winning_source("uploaded_images", "i")}
1724
+ WHERE #{present("i.context_message_id", image_id)}
1725
+ SQL
1726
+ end
1727
+ if sources.empty?
1728
+ return(
1729
+ create_empty(
1730
+ table,
1731
+ [
1732
+ "`message_id` VARCHAR(255) NOT NULL",
1733
+ "`image_id` VARCHAR(255) NOT NULL",
1734
+ "`url` TEXT NULL",
1735
+ "`title` VARCHAR(255) NULL",
1736
+ "`description` TEXT NULL",
1737
+ "`source_sid` BIGINT NOT NULL"
1738
+ ],
1739
+ indexes: ["PRIMARY KEY (`message_id`, `image_id`)"]
1740
+ )
1741
+ )
1742
+ end
1743
+ statement(<<~SQL)
1744
+ CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
1745
+ WITH ImageRows AS (
1746
+ #{sources.join("\nUNION ALL\n")}
1747
+ ), RankedImageSources AS (
1748
+ SELECT ImageRows.*,
1749
+ ROW_NUMBER() OVER (
1750
+ PARTITION BY message_id, image_id, priority
1751
+ ORDER BY source_sid DESC
1752
+ ) AS source_rn
1753
+ FROM ImageRows
1754
+ WHERE message_id IS NOT NULL AND image_id IS NOT NULL
1755
+ )
1756
+ SELECT message_id, image_id,
1757
+ COALESCE(
1758
+ MAX(CASE WHEN priority = 2 THEN url END),
1759
+ MAX(CASE WHEN priority = 1 THEN url END)
1760
+ ) AS url,
1761
+ COALESCE(
1762
+ MAX(CASE WHEN priority = 1 THEN title END),
1763
+ MAX(CASE WHEN priority = 2 THEN title END)
1764
+ ) AS title,
1765
+ COALESCE(
1766
+ MAX(CASE WHEN priority = 1 THEN description END),
1767
+ MAX(CASE WHEN priority = 2 THEN description END)
1768
+ ) AS description,
1769
+ COALESCE(
1770
+ MAX(CASE WHEN priority = 2 THEN source_sid END),
1771
+ MAX(CASE WHEN priority = 1 THEN source_sid END)
1772
+ ) AS source_sid
1773
+ FROM RankedImageSources
1774
+ WHERE source_rn = 1
1775
+ GROUP BY message_id, image_id
1776
+ SQL
1777
+ add_primary_key(table, %w[message_id image_id])
1778
+ end
1779
+
1780
+ def create_attachments
1781
+ table = "#{PREFIX}_attachments"
1782
+ unless canonical_source?("attachments", "context_message_id")
1783
+ return(
1784
+ create_empty(
1785
+ table,
1786
+ [
1787
+ "`message_id` VARCHAR(255) NOT NULL",
1788
+ "`attachment_id` VARCHAR(255) NOT NULL",
1789
+ "`file_name` TEXT NULL",
1790
+ "`file_size` BIGINT NULL",
1791
+ "`content_type` VARCHAR(255) NULL",
1792
+ "`url` TEXT NULL",
1793
+ "`description` TEXT NULL",
1794
+ "`position` BIGINT NULL",
1795
+ "`source_sid` BIGINT NOT NULL"
1796
+ ],
1797
+ indexes: [
1798
+ "PRIMARY KEY (`message_id`, `attachment_id`)",
1799
+ "KEY `idx_message_id` (`message_id`)"
1800
+ ]
1801
+ )
1802
+ )
1803
+ end
1804
+
1805
+ attachment_id =
1806
+ optional("attachments", "data_id", "a.id", table_alias: "a")
1807
+ file_name =
1808
+ optional(
1809
+ "attachments",
1810
+ "data_filename",
1811
+ optional("attachments", "data_file_name", table_alias: "a"),
1812
+ table_alias: "a"
1813
+ )
1814
+ file_size =
1815
+ optional(
1816
+ "attachments",
1817
+ "data_filesize",
1818
+ optional("attachments", "data_file_size", table_alias: "a"),
1819
+ table_alias: "a"
1820
+ )
1821
+ statement(<<~SQL)
1822
+ CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
1823
+ WITH #{canonical_cte("attachments")}, AttachmentRows AS (
1824
+ SELECT #{key("a.context_message_id")} AS message_id,
1825
+ #{key(attachment_id)} AS attachment_id,
1826
+ #{text(file_name)} AS file_name,
1827
+ #{file_size} AS file_size,
1828
+ #{text(optional("attachments", "data_content_type", table_alias: "a"))} AS content_type,
1829
+ #{text(optional("attachments", "data_url", table_alias: "a"))} AS url,
1830
+ #{text(optional("attachments", "data_description", table_alias: "a"))} AS description,
1831
+ #{optional("attachments", "data_position", table_alias: "a")} AS position,
1832
+ a._sid AS source_sid
1833
+ FROM RankedRows a
1834
+ WHERE a.occurrence_rn = 1
1835
+ AND #{present("a.context_message_id", attachment_id)}
1836
+ ), RankedAttachments AS (
1837
+ SELECT AttachmentRows.*,
1838
+ ROW_NUMBER() OVER (
1839
+ PARTITION BY message_id, attachment_id
1840
+ ORDER BY source_sid DESC
1841
+ ) AS attachment_rn
1842
+ FROM AttachmentRows
1843
+ )
1844
+ SELECT message_id, attachment_id, file_name, file_size, content_type,
1845
+ url, description, position, source_sid
1846
+ FROM RankedAttachments
1847
+ WHERE attachment_rn = 1
1848
+ SQL
1849
+ add_primary_key(table, %w[message_id attachment_id])
1850
+ add_index(table, "idx_message_id", %w[message_id])
1851
+ end
1852
+
1853
+ # The exporter's files.ndjson maps every downloaded URL to a
1854
+ # content-addressed blob (`files/<xx>/<sha256>`, no extension). Rows
1855
+ # with status `duplicate` still carry the sha and path (same blob,
1856
+ # different URL). Rows without a path (errors, bot checks, missing)
1857
+ # are retained so the converter can report them; consumers filter
1858
+ # `path IS NOT NULL`. `url_key` is SHA1(url) because URLs exceed the
1859
+ # 255-character key budget.
1860
+ def create_files
1861
+ table = "#{PREFIX}_files"
1862
+ unless canonical_source?("files") && has_column?("files", "url")
1863
+ return(
1864
+ create_empty(
1865
+ table,
1866
+ [
1867
+ "`url_key` CHAR(40) NOT NULL",
1868
+ "`url` TEXT NOT NULL",
1869
+ "`sha256` CHAR(64) NULL",
1870
+ "`path` VARCHAR(255) NULL",
1871
+ "`bytes` BIGINT NULL",
1872
+ "`content_type` VARCHAR(255) NULL",
1873
+ "`status` VARCHAR(64) NULL",
1874
+ "`source_collection` VARCHAR(64) NULL",
1875
+ "`source_id` VARCHAR(255) NULL",
1876
+ "`source_message_id` VARCHAR(255) NULL",
1877
+ "`source_sid` BIGINT NOT NULL"
1878
+ ],
1879
+ indexes: [
1880
+ "PRIMARY KEY (`url_key`)",
1881
+ "KEY `idx_source` (`source_collection`, `source_message_id`)"
1882
+ ]
1883
+ )
1884
+ )
1885
+ end
1886
+
1887
+ statement(<<~SQL)
1888
+ CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
1889
+ WITH #{canonical_cte("files")}
1890
+ SELECT
1891
+ SHA1(f.url) AS url_key,
1892
+ #{text("f.url")} AS url,
1893
+ #{key(optional("files", "data_sha256", table_alias: "f"))} AS sha256,
1894
+ #{key(optional("files", "data_path", table_alias: "f"))} AS path,
1895
+ #{optional("files", "data_bytes", table_alias: "f")} AS bytes,
1896
+ #{key(optional("files", "data_content_type", table_alias: "f"))} AS content_type,
1897
+ #{key(optional("files", "data_status", table_alias: "f"))} AS status,
1898
+ #{key(optional("files", "data_source_collection", table_alias: "f"))} AS source_collection,
1899
+ #{key(optional("files", "data_source_id", table_alias: "f"))} AS source_id,
1900
+ #{key(optional("files", "data_source_message_id", table_alias: "f"))} AS source_message_id,
1901
+ f._sid AS source_sid
1902
+ FROM RankedRows f
1903
+ WHERE f.occurrence_rn = 1
1904
+ AND #{present("f.url")}
1905
+ SQL
1906
+ add_primary_key(table, %w[url_key])
1907
+ add_index(
1908
+ table,
1909
+ "idx_source",
1910
+ %w[source_collection source_message_id]
1911
+ )
1912
+ end
1913
+
1914
+ def create_source_messages
1915
+ table = "#{PREFIX}_source_messages"
1916
+ statement(<<~SQL)
1917
+ CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
1918
+ WITH #{canonical_cte("messages")}
1919
+ SELECT
1920
+ m._sid AS source_sid,
1921
+ #{key("m.data_id")} AS id,
1922
+ m.data_author_id AS author_id,
1923
+ #{key("m.data_board_id")} AS board_id,
1924
+ #{key("m.data_conversation_id")} AS conversation_id,
1925
+ #{optional("messages", "data_parent_id", table_alias: "m")} AS parent_id,
1926
+ m.data_depth AS depth,
1927
+ m.data_subject AS subject,
1928
+ m.data_body AS body,
1929
+ m.data_post_time AS post_time,
1930
+ #{boolean_value("m.data_read_only")} AS read_only,
1931
+ COALESCE(#{optional("messages", "data_is_solution", "0", table_alias: "m")}, 0) AS is_solution,
1932
+ #{optional("messages", "data_solution_data_message_id", table_alias: "m")} AS solution_message_id,
1933
+ #{optional("messages", "data_solution_data_accepter_id", table_alias: "m")} AS solution_accepter_id,
1934
+ #{optional("messages", "data_solution_data_time", table_alias: "m")} AS solution_time
1935
+ FROM RankedRows m
1936
+ WHERE m.occurrence_rn = 1
1937
+ SQL
1938
+ add_primary_key(table, %w[id])
1939
+ add_index(table, "idx_conversation_id", %w[conversation_id])
1940
+ add_index(table, "idx_board_id", %w[board_id])
1941
+ end
1942
+
1943
+ def create_metadata
1944
+ statement(<<~SQL)
1945
+ CREATE TABLE `#{METADATA_TABLE}` (
1946
+ `profile` VARCHAR(64) NOT NULL,
1947
+ `profile_version` BIGINT NOT NULL,
1948
+ `tool_version` VARCHAR(64) NOT NULL,
1949
+ PRIMARY KEY (`profile`)
1950
+ ) #{TABLE_OPTIONS}
1951
+ SQL
1952
+ statement(<<~SQL)
1953
+ INSERT INTO `#{METADATA_TABLE}` (`profile`, `profile_version`, `tool_version`)
1954
+ VALUES ('khoros_api_export', #{VERSION}, '#{StructuredDataToSql::VERSION.gsub("'", "''")}')
1955
+ SQL
1956
+ end
1957
+ end
1958
+ end
1959
+ end
1960
+ end