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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff2d9500aa62ada917f7f65d973d3c9e5495506cfcc6cf06011328482fb50288
4
- data.tar.gz: f5440b2010078d1b011106ba82c4256d44098501f91131706c80ad45d259de5f
3
+ metadata.gz: 0c7a2d7a571374df01b77f059c243d1ddf43ab4ac51fe85f21c3b5a8ba7d1ed4
4
+ data.tar.gz: bca5b0b6e07ff5c7de2c254bcfa336b27f9ef35c949a6b1e3db4618a61820030
5
5
  SHA512:
6
- metadata.gz: b443a51fdabf4dc975562fae2ed7e6cde39b616597cb640134dec3d470cc17f9ec27a46289c746b5ce3375d83861e86f0769e84266ad274f4cc7c95f52076b13
7
- data.tar.gz: ebe2b6d53b667d71d8d8260eaaf39990cb56ffe6911117a9f2513e2da49da343b10fd1e848a2571a44d65bed1edc06fecd872ce6d962d0d34a800cbdd1ade0bf
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(&:completed?)
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 3 adds source-message view counts.
21
- VERSION = 3
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
- latest_count = latest.public_send(counter)
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
- "Latest completed run ##{latest.index}#{window.empty? ? "" : " (#{window})"}: " \
740
- "#{Format.format_count(latest_count)}#{latest_count.positive? ? " — #{breakdown_text(latest_tallies)}" : ""}"
751
+ attributed_line(
752
+ "Latest completed run",
753
+ latest,
754
+ manifest,
755
+ table,
756
+ counter,
757
+ total,
758
+ unattributed
759
+ )
741
760
  ]
742
- unattributed = total - declared
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: @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(@profile_tables).write(out)
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StructuredDataToSql
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: structured_data_to_sql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Discourse Team