structured_data_to_sql 0.2.0 → 0.3.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 +4 -4
- data/lib/structured_data_to_sql/diagnostics_report.rb +4 -2
- data/lib/structured_data_to_sql/json/exporter_manifest.rb +34 -1
- data/lib/structured_data_to_sql/json/profiles/khoros_api_export.rb +139 -14
- data/lib/structured_data_to_sql/json_converter.rb +15 -2
- data/lib/structured_data_to_sql/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c7a2d7a571374df01b77f059c243d1ddf43ab4ac51fe85f21c3b5a8ba7d1ed4
|
|
4
|
+
data.tar.gz: bca5b0b6e07ff5c7de2c254bcfa336b27f9ef35c949a6b1e3db4618a61820030
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7673f7ac81093a5c3e533b300e71f422a1026ac6c814270f400720f571b09d6918b6badffe99c7c8419983071a68cba51aac951bf7c1348c78c9a48c6afc8624
|
|
7
|
+
data.tar.gz: de4c881ae651cb0ff5ef6e9973a44d3e90d9763a6ea7521e34a7174c661bd361bb30c0aa97333ff387aa6dd1d2428a8f9958e12d4de8b75ea6ec9d1007bbbd5d
|
|
@@ -150,8 +150,10 @@ module StructuredDataToSql
|
|
|
150
150
|
return lines
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
-
lines << "Quality signals are scoped to the latest run whose manifest entry is `completed`, " \
|
|
154
|
-
"by attributing the last _N_ rows of the append-only errors/gaps files to it (_N_ = that run's declared count)
|
|
153
|
+
lines << "Quality signals are scoped to the latest content run whose manifest entry is `completed`, " \
|
|
154
|
+
"by attributing the last _N_ rows of the append-only errors/gaps files to it (_N_ = that run's declared count), " \
|
|
155
|
+
"less whatever later runs declared. A `structure_repair` run that follows it is reported on its own line; " \
|
|
156
|
+
"a later run that never finalized its entry leaves the breakdown unattributable and it is withheld. " \
|
|
155
157
|
"Rows beyond the sum of declared counts belong to runs that never finalized their entry."
|
|
156
158
|
lines << ""
|
|
157
159
|
lines << "| Run | Status | Started | Finished | Mode | Credential | Emails | Errors | Gaps |"
|
|
@@ -26,6 +26,23 @@ module StructuredDataToSql
|
|
|
26
26
|
def completed?
|
|
27
27
|
status == "completed"
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
# A structure repair appends nodes and its own error/gap rows to an
|
|
31
|
+
# existing export; it is a run, but not the content export the rows
|
|
32
|
+
# were converted from.
|
|
33
|
+
def repair?
|
|
34
|
+
mode == "structure_repair"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def content?
|
|
38
|
+
!repair?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# An entry the exporter closed, whatever the outcome. A run killed
|
|
42
|
+
# outright stays `running` with no counts, so its rows are undeclared.
|
|
43
|
+
def finalized?
|
|
44
|
+
status != "running" && !finished_at.nil?
|
|
45
|
+
end
|
|
29
46
|
end
|
|
30
47
|
|
|
31
48
|
FILENAME = "manifest.json"
|
|
@@ -64,8 +81,24 @@ module StructuredDataToSql
|
|
|
64
81
|
parse
|
|
65
82
|
end
|
|
66
83
|
|
|
84
|
+
# The latest completed content run: what the quality signals are scoped to.
|
|
67
85
|
def latest_completed
|
|
68
|
-
@runs.reverse.find
|
|
86
|
+
@runs.reverse.find { |run| run.completed? && run.content? }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def latest_completed_repair
|
|
90
|
+
@runs.reverse.find { |run| run.completed? && run.repair? }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Rows declared by the runs after +run+. The files are append-only, so a
|
|
94
|
+
# run's rows end where the next run's begin.
|
|
95
|
+
def rows_after(run, counter)
|
|
96
|
+
@runs.drop(run.index + 1).sum(&counter)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Later runs whose rows are not declared, so their position is unknown.
|
|
100
|
+
def unfinalized_after(run)
|
|
101
|
+
@runs.drop(run.index + 1).reject(&:finalized?)
|
|
69
102
|
end
|
|
70
103
|
|
|
71
104
|
def errors_total
|
|
@@ -17,8 +17,8 @@ module StructuredDataToSql
|
|
|
17
17
|
NAME = :khoros_api_export
|
|
18
18
|
# Bumped in lockstep with PROFILE_VERSION in the Khoros converter's
|
|
19
19
|
# KhorosApiExportContentStaging whenever a canonical table's shape or
|
|
20
|
-
# semantics change. Version
|
|
21
|
-
VERSION =
|
|
20
|
+
# semantics change. Version 4 adds optional API subscriptions.
|
|
21
|
+
VERSION = 4
|
|
22
22
|
PREFIX = "discourse_khoros_api"
|
|
23
23
|
METADATA_TABLE = "_structured_data_to_sql_profiles"
|
|
24
24
|
# Explicit storage options on every canonical table so they never
|
|
@@ -62,6 +62,7 @@ module StructuredDataToSql
|
|
|
62
62
|
attachments
|
|
63
63
|
files
|
|
64
64
|
source_messages
|
|
65
|
+
subscriptions
|
|
65
66
|
].map { |name| "#{PREFIX}_#{name}" }.freeze
|
|
66
67
|
USER_STRING_FIELDS = {
|
|
67
68
|
id: %w[data_id],
|
|
@@ -222,6 +223,21 @@ module StructuredDataToSql
|
|
|
222
223
|
}
|
|
223
224
|
}.freeze
|
|
224
225
|
OPTIONAL = {
|
|
226
|
+
"subscriptions" => {
|
|
227
|
+
required: {
|
|
228
|
+
subscription_id: %w[id],
|
|
229
|
+
context_user_id: %w[context_user_id],
|
|
230
|
+
subscriber_id: %w[data_subscriber_id],
|
|
231
|
+
target_type: %w[data_target_type],
|
|
232
|
+
target_id: %w[data_target_id]
|
|
233
|
+
},
|
|
234
|
+
indexed: {
|
|
235
|
+
subscription_id: %w[id],
|
|
236
|
+
context_user_id: %w[context_user_id],
|
|
237
|
+
subscriber_id: %w[data_subscriber_id],
|
|
238
|
+
target_id: %w[data_target_id]
|
|
239
|
+
}
|
|
240
|
+
},
|
|
225
241
|
"user_profiles" => {
|
|
226
242
|
required: {
|
|
227
243
|
user_id: %w[id]
|
|
@@ -718,7 +734,7 @@ module StructuredDataToSql
|
|
|
718
734
|
latest = manifest.latest_completed
|
|
719
735
|
if latest.nil?
|
|
720
736
|
return [
|
|
721
|
-
"Latest run: not scoped — no completed run in manifest.json; showing lifetime totals"
|
|
737
|
+
"Latest run: not scoped — no completed content run in manifest.json; showing lifetime totals"
|
|
722
738
|
]
|
|
723
739
|
end
|
|
724
740
|
|
|
@@ -730,22 +746,77 @@ module StructuredDataToSql
|
|
|
730
746
|
]
|
|
731
747
|
end
|
|
732
748
|
|
|
733
|
-
|
|
734
|
-
latest_rows = @quality_signal_rows[table].last(latest_count)
|
|
735
|
-
latest_tallies = Hash.new(0)
|
|
736
|
-
latest_rows.each { |label| latest_tallies[label] += 1 }
|
|
737
|
-
window = [latest.started_at, latest.finished_at].compact.join(" → ")
|
|
749
|
+
unattributed = total - declared
|
|
738
750
|
lines = [
|
|
739
|
-
|
|
740
|
-
"
|
|
751
|
+
attributed_line(
|
|
752
|
+
"Latest completed run",
|
|
753
|
+
latest,
|
|
754
|
+
manifest,
|
|
755
|
+
table,
|
|
756
|
+
counter,
|
|
757
|
+
total,
|
|
758
|
+
unattributed
|
|
759
|
+
)
|
|
741
760
|
]
|
|
742
|
-
|
|
761
|
+
repair = manifest.latest_completed_repair
|
|
762
|
+
if repair && repair.index > latest.index
|
|
763
|
+
lines << attributed_line(
|
|
764
|
+
"Latest structure repair",
|
|
765
|
+
repair,
|
|
766
|
+
manifest,
|
|
767
|
+
table,
|
|
768
|
+
counter,
|
|
769
|
+
total,
|
|
770
|
+
unattributed
|
|
771
|
+
)
|
|
772
|
+
end
|
|
743
773
|
if unattributed.positive?
|
|
744
774
|
lines << "Unattributed: #{Format.format_count(unattributed)} row(s) written by runs that did not finalize their manifest entry"
|
|
745
775
|
end
|
|
746
776
|
lines
|
|
747
777
|
end
|
|
748
778
|
|
|
779
|
+
# A run's rows sit before whatever later runs appended, so its slice
|
|
780
|
+
# ends where theirs begin — provided every later run declared its
|
|
781
|
+
# rows. Undeclared rows that may sit after this run's make the slice a
|
|
782
|
+
# guess, and a guess is not shown as a breakdown.
|
|
783
|
+
def attributed_line(
|
|
784
|
+
label,
|
|
785
|
+
run,
|
|
786
|
+
manifest,
|
|
787
|
+
table,
|
|
788
|
+
counter,
|
|
789
|
+
total,
|
|
790
|
+
unattributed
|
|
791
|
+
)
|
|
792
|
+
count = run.public_send(counter)
|
|
793
|
+
window = [run.started_at, run.finished_at].compact.join(" → ")
|
|
794
|
+
head =
|
|
795
|
+
"#{label} ##{run.index}#{window.empty? ? "" : " (#{window})"}: " \
|
|
796
|
+
"#{Format.format_count(count)}"
|
|
797
|
+
undeclared =
|
|
798
|
+
unattributed.positive? ? manifest.unfinalized_after(run) : []
|
|
799
|
+
unless undeclared.empty?
|
|
800
|
+
blamed =
|
|
801
|
+
undeclared
|
|
802
|
+
.map { |later| "##{later.index} (#{later.status})" }
|
|
803
|
+
.join(", ")
|
|
804
|
+
return(
|
|
805
|
+
"#{head} — breakdown not attributable: run #{blamed} did not finalize its " \
|
|
806
|
+
"entry, so undeclared rows may sit after this run's"
|
|
807
|
+
)
|
|
808
|
+
end
|
|
809
|
+
return head unless count.positive?
|
|
810
|
+
|
|
811
|
+
after = manifest.rows_after(run, counter)
|
|
812
|
+
tallies = Hash.new(0)
|
|
813
|
+
@quality_signal_rows[table]
|
|
814
|
+
.first(total - after)
|
|
815
|
+
.last(count)
|
|
816
|
+
.each { |row_label| tallies[row_label] += 1 }
|
|
817
|
+
"#{head} — #{breakdown_text(tallies)}"
|
|
818
|
+
end
|
|
819
|
+
|
|
749
820
|
def breakdown_text(tallies)
|
|
750
821
|
tallies
|
|
751
822
|
.sort_by { |label, count| [-count, label] }
|
|
@@ -878,9 +949,10 @@ module StructuredDataToSql
|
|
|
878
949
|
out.write("DROP TABLE IF EXISTS `#{METADATA_TABLE}`;\n")
|
|
879
950
|
end
|
|
880
951
|
|
|
881
|
-
def initialize(table_columns)
|
|
952
|
+
def initialize(table_columns, supplied_collections: [])
|
|
882
953
|
@table_columns =
|
|
883
954
|
table_columns.transform_values { |value| Array(value).map(&:to_s) }
|
|
955
|
+
@supplied_collections = supplied_collections
|
|
884
956
|
end
|
|
885
957
|
|
|
886
958
|
def write(out)
|
|
@@ -905,6 +977,7 @@ module StructuredDataToSql
|
|
|
905
977
|
create_attachments
|
|
906
978
|
create_files
|
|
907
979
|
create_source_messages
|
|
980
|
+
create_subscriptions
|
|
908
981
|
create_metadata
|
|
909
982
|
ensure
|
|
910
983
|
@out = nil
|
|
@@ -1942,18 +2015,70 @@ module StructuredDataToSql
|
|
|
1942
2015
|
add_index(table, "idx_board_id", %w[board_id])
|
|
1943
2016
|
end
|
|
1944
2017
|
|
|
2018
|
+
def create_subscriptions
|
|
2019
|
+
table = "#{PREFIX}_subscriptions"
|
|
2020
|
+
unless canonical_source?(
|
|
2021
|
+
"subscriptions",
|
|
2022
|
+
"context_user_id",
|
|
2023
|
+
"data_subscriber_id",
|
|
2024
|
+
"data_target_type",
|
|
2025
|
+
"data_target_id"
|
|
2026
|
+
)
|
|
2027
|
+
create_empty(
|
|
2028
|
+
table,
|
|
2029
|
+
[
|
|
2030
|
+
"`id` VARCHAR(255) NOT NULL",
|
|
2031
|
+
"`context_user_id` VARCHAR(255) NOT NULL",
|
|
2032
|
+
"`subscriber_id` VARCHAR(255) NOT NULL",
|
|
2033
|
+
"`target_type` VARCHAR(255) NOT NULL",
|
|
2034
|
+
"`target_id` VARCHAR(255) NOT NULL",
|
|
2035
|
+
"`source_sid` BIGINT NOT NULL"
|
|
2036
|
+
],
|
|
2037
|
+
indexes: [
|
|
2038
|
+
"PRIMARY KEY (`id`, `context_user_id`)",
|
|
2039
|
+
"KEY `idx_subscriber_id` (`subscriber_id`)"
|
|
2040
|
+
]
|
|
2041
|
+
)
|
|
2042
|
+
return
|
|
2043
|
+
end
|
|
2044
|
+
|
|
2045
|
+
statement(<<~SQL)
|
|
2046
|
+
CREATE TABLE `#{table}` #{TABLE_OPTIONS} AS
|
|
2047
|
+
SELECT
|
|
2048
|
+
#{key("s.id")} AS id,
|
|
2049
|
+
#{key("s.context_user_id")} AS context_user_id,
|
|
2050
|
+
#{key("s.data_subscriber_id")} AS subscriber_id,
|
|
2051
|
+
#{key("s.data_target_type")} AS target_type,
|
|
2052
|
+
#{key("s.data_target_id")} AS target_id,
|
|
2053
|
+
s._sid AS source_sid
|
|
2054
|
+
FROM (
|
|
2055
|
+
SELECT source_row.*,
|
|
2056
|
+
ROW_NUMBER() OVER (
|
|
2057
|
+
PARTITION BY source_row.id, source_row.context_user_id
|
|
2058
|
+
ORDER BY source_row._sid DESC
|
|
2059
|
+
) AS occurrence_rn
|
|
2060
|
+
FROM `subscriptions` source_row
|
|
2061
|
+
) s
|
|
2062
|
+
WHERE s.occurrence_rn = 1
|
|
2063
|
+
AND #{present("s.id", "s.context_user_id", "s.data_subscriber_id", "s.data_target_type", "s.data_target_id")}
|
|
2064
|
+
SQL
|
|
2065
|
+
add_primary_key(table, %w[id context_user_id])
|
|
2066
|
+
add_index(table, "idx_subscriber_id", %w[subscriber_id])
|
|
2067
|
+
end
|
|
2068
|
+
|
|
1945
2069
|
def create_metadata
|
|
1946
2070
|
statement(<<~SQL)
|
|
1947
2071
|
CREATE TABLE `#{METADATA_TABLE}` (
|
|
1948
2072
|
`profile` VARCHAR(64) NOT NULL,
|
|
1949
2073
|
`profile_version` BIGINT NOT NULL,
|
|
1950
2074
|
`tool_version` VARCHAR(64) NOT NULL,
|
|
2075
|
+
`subscriptions_supplied` TINYINT(1) NOT NULL,
|
|
1951
2076
|
PRIMARY KEY (`profile`)
|
|
1952
2077
|
) #{TABLE_OPTIONS}
|
|
1953
2078
|
SQL
|
|
1954
2079
|
statement(<<~SQL)
|
|
1955
|
-
INSERT INTO `#{METADATA_TABLE}` (`profile`, `profile_version`, `tool_version`)
|
|
1956
|
-
VALUES ('khoros_api_export', #{VERSION}, '#{StructuredDataToSql::VERSION.gsub("'", "''")}')
|
|
2080
|
+
INSERT INTO `#{METADATA_TABLE}` (`profile`, `profile_version`, `tool_version`, `subscriptions_supplied`)
|
|
2081
|
+
VALUES ('khoros_api_export', #{VERSION}, '#{StructuredDataToSql::VERSION.gsub("'", "''")}', #{@supplied_collections.include?("subscriptions") ? 1 : 0})
|
|
1957
2082
|
SQL
|
|
1958
2083
|
end
|
|
1959
2084
|
end
|
|
@@ -192,6 +192,8 @@ module StructuredDataToSql
|
|
|
192
192
|
raise UsageError, "No JSON files found in #{source}" if files.empty?
|
|
193
193
|
|
|
194
194
|
files_to_process, skipped = files.partition { |file| process_file?(file) }
|
|
195
|
+
@profile_supplied_collections =
|
|
196
|
+
files_to_process.map { |file| json_basename(file) }.to_set if @profile
|
|
195
197
|
@stats[:files_skipped] += skipped.length
|
|
196
198
|
if files_to_process.empty?
|
|
197
199
|
raise UsageError,
|
|
@@ -693,7 +695,15 @@ module StructuredDataToSql
|
|
|
693
695
|
).each_record(
|
|
694
696
|
counting,
|
|
695
697
|
recover: @recover_truncated,
|
|
696
|
-
ndjson:
|
|
698
|
+
ndjson:
|
|
699
|
+
(
|
|
700
|
+
if @ndjson == :auto &&
|
|
701
|
+
path.to_s.match?(/\.(?:ndjson|jsonl)(?:\.gz)?\z/)
|
|
702
|
+
true
|
|
703
|
+
else
|
|
704
|
+
@ndjson
|
|
705
|
+
end
|
|
706
|
+
),
|
|
697
707
|
&block
|
|
698
708
|
)
|
|
699
709
|
end
|
|
@@ -784,7 +794,10 @@ module StructuredDataToSql
|
|
|
784
794
|
@profile_validator.warnings
|
|
785
795
|
end
|
|
786
796
|
warnings.each { |warning| emit_warning(warning) }
|
|
787
|
-
profile_class.new(
|
|
797
|
+
profile_class.new(
|
|
798
|
+
@profile_tables,
|
|
799
|
+
supplied_collections: @profile_supplied_collections
|
|
800
|
+
).write(out)
|
|
788
801
|
end
|
|
789
802
|
|
|
790
803
|
def write_profile_preamble(out)
|