structured_data_to_sql 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +9 -0
- data/bin/structured-data-to-sql +6 -0
- data/lib/structured_data_to_sql/cli.rb +405 -0
- data/lib/structured_data_to_sql/conversion_result.rb +94 -0
- data/lib/structured_data_to_sql/diagnostic.rb +82 -0
- data/lib/structured_data_to_sql/diagnostics_report.rb +187 -0
- data/lib/structured_data_to_sql/errors.rb +48 -0
- data/lib/structured_data_to_sql/format.rb +41 -0
- data/lib/structured_data_to_sql/io_support.rb +154 -0
- data/lib/structured_data_to_sql/json/exporter_manifest.rb +127 -0
- data/lib/structured_data_to_sql/json/json_schema_loader.rb +310 -0
- data/lib/structured_data_to_sql/json/profiles/khoros_api_export.rb +1960 -0
- data/lib/structured_data_to_sql/json/profiles.rb +14 -0
- data/lib/structured_data_to_sql/json/record_streamer.rb +477 -0
- data/lib/structured_data_to_sql/json/schema_inferrer.rb +150 -0
- data/lib/structured_data_to_sql/json/shredder.rb +241 -0
- data/lib/structured_data_to_sql/json/sql_emitter.rb +198 -0
- data/lib/structured_data_to_sql/json_converter.rb +913 -0
- data/lib/structured_data_to_sql/mysql_dump_xml/invalid_character_report.rb +82 -0
- data/lib/structured_data_to_sql/mysql_dump_xml/sanitizer.rb +152 -0
- data/lib/structured_data_to_sql/mysql_dump_xml/sax_parser.rb +111 -0
- data/lib/structured_data_to_sql/mysql_dump_xml/sql_emitter.rb +104 -0
- data/lib/structured_data_to_sql/mysql_dump_xml/table_data_filter.rb +343 -0
- data/lib/structured_data_to_sql/mysql_dump_xml/table_discovery.rb +651 -0
- data/lib/structured_data_to_sql/mysql_dump_xml/table_structure.rb +98 -0
- data/lib/structured_data_to_sql/mysql_dump_xml_converter.rb +4 -0
- data/lib/structured_data_to_sql/options.rb +89 -0
- data/lib/structured_data_to_sql/progress_reporter.rb +348 -0
- data/lib/structured_data_to_sql/sql_text.rb +29 -0
- data/lib/structured_data_to_sql/version.rb +5 -0
- data/lib/structured_data_to_sql/xml_converter.rb +651 -0
- data/lib/structured_data_to_sql/xml_dump_converter.rb +4 -0
- data/lib/structured_data_to_sql.rb +54 -0
- metadata +120 -0
|
@@ -0,0 +1,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
|