active_sanction 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. metadata +236 -0
@@ -0,0 +1,124 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "json"
7
+ require "active_sanction/entity"
8
+ require "active_sanction/snapshot"
9
+ require "active_sanction/storage/base"
10
+ require "active_sanction/storage/active_record/row"
11
+
12
+ module ActiveSanction
13
+ module Storage
14
+ class ActiveRecord < Base
15
+ # Turns the rows of one stored list back into a Snapshot.
16
+ #
17
+ # Four queries and no models. Every one of them is a `pluck` against an
18
+ # indexed `snapshot_id`, because the alternative -- instantiating 19,015
19
+ # Row::Entity objects and letting their associations load, or worse, not
20
+ # letting them -- spends the whole cost of ActiveRecord's object graph on
21
+ # data that is about to be turned into different objects anyway.
22
+ #
23
+ # The Snapshot is rebuilt with the checksum that was stored beside it,
24
+ # which makes construction the verification: Snapshot recomputes the
25
+ # digest over the records that actually came back and refuses to build if
26
+ # it does not match. A row deleted by hand, a write that half landed, a
27
+ # column edited in a console -- all of them raise rather than screen a
28
+ # customer against a list that is quietly missing people.
29
+ #
30
+ # @api private
31
+ class Reader
32
+ extend T::Sig
33
+
34
+ # @api private
35
+ ENTITY_COLUMNS = T.let(%i[
36
+ id external_id source source_ref entity_type dates_of_birth nationalities programs listed_on remarks
37
+ ].freeze, T::Array[Symbol])
38
+
39
+ # @api private
40
+ NAME_COLUMNS = T.let(%i[entity_id value kind quality script].freeze, T::Array[Symbol])
41
+ # @api private
42
+ ADDRESS_COLUMNS = T.let(
43
+ %i[entity_id street city state_province postal_code country note].freeze, T::Array[Symbol]
44
+ )
45
+ # @api private
46
+ IDENTIFIER_COLUMNS = T.let(
47
+ %i[entity_id kind value country issued_on expires_on note].freeze, T::Array[Symbol]
48
+ )
49
+
50
+ # Identifier members that are stored as JSON and rebuilt as dates.
51
+ #
52
+ # @api private
53
+ IDENTIFIER_DATES = T.let(%i[issued_on expires_on].freeze, T::Array[Symbol])
54
+
55
+ sig { params(row: T.untyped).void }
56
+ def initialize(row)
57
+ @row = T.let(row, T.untyped)
58
+ end
59
+
60
+ sig { returns(Snapshot) }
61
+ def call
62
+ Snapshot.new(
63
+ source: @row.source.to_sym, entities: entities, fetched_at: @row.fetched_at.to_time,
64
+ checksum: @row.checksum, record_count: @row.record_count, schema_version: @row.schema_version,
65
+ source_version: @row.source_version
66
+ )
67
+ end
68
+
69
+ private
70
+
71
+ sig { returns(T::Array[Entity]) }
72
+ def entities
73
+ names = children(Row::Name, NAME_COLUMNS)
74
+ addresses = children(Row::Address, ADDRESS_COLUMNS)
75
+ identifiers = identifier_children
76
+ rows(Row::Entity, ENTITY_COLUMNS).map do |cells|
77
+ entity(cells, names, addresses, identifiers)
78
+ end
79
+ end
80
+
81
+ sig do
82
+ params(cells: T::Hash[Symbol, T.untyped], names: T.untyped, addresses: T.untyped,
83
+ identifiers: T.untyped).returns(Entity)
84
+ end
85
+ def entity(cells, names, addresses, identifiers)
86
+ key = cells[:id]
87
+ Entity.from_h(
88
+ id: cells[:external_id], source: cells[:source], source_ref: cells[:source_ref],
89
+ type: cells[:entity_type], names: names[key], addresses: addresses[key], identifiers: identifiers[key],
90
+ dates_of_birth: parse(cells[:dates_of_birth]), nationalities: parse(cells[:nationalities]),
91
+ programs: parse(cells[:programs]), listed_on: parse(cells[:listed_on]), remarks: cells[:remarks]
92
+ )
93
+ end
94
+
95
+ sig { returns(T::Hash[T.untyped, T::Array[T::Hash[Symbol, T.untyped]]]) }
96
+ def identifier_children
97
+ children(Row::Identifier, IDENTIFIER_COLUMNS).transform_values do |list|
98
+ list.map { |member| member.merge(IDENTIFIER_DATES.to_h { |date| [date, parse(member[date])] }) }
99
+ end
100
+ end
101
+
102
+ # Every child of every entity on this list, in one query, grouped by
103
+ # the entity they hang off and left in the order they were written.
104
+ sig do
105
+ params(model: T.untyped, columns: T::Array[Symbol])
106
+ .returns(T::Hash[T.untyped, T::Array[T::Hash[Symbol, T.untyped]]])
107
+ end
108
+ def children(model, columns)
109
+ model.where(snapshot_id: @row.id).order(:entity_id, :position).pluck(*columns)
110
+ .group_by(&:first)
111
+ .transform_values { |group| group.map { |cells| columns.drop(1).zip(cells.drop(1)).to_h } }
112
+ end
113
+
114
+ sig { params(model: T.untyped, columns: T::Array[Symbol]).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
115
+ def rows(model, columns)
116
+ model.where(snapshot_id: @row.id).order(:position).pluck(*columns).map { |cells| columns.zip(cells).to_h }
117
+ end
118
+
119
+ sig { params(json: T.untyped).returns(T.untyped) }
120
+ def parse(json) = json.nil? ? nil : JSON.parse(json)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,175 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_record"
7
+ require "active_sanction/identifier"
8
+ require "active_sanction/storage/base"
9
+
10
+ module ActiveSanction
11
+ module Storage
12
+ class ActiveRecord < Base
13
+ # The five tables, as ActiveRecord models.
14
+ #
15
+ # Row::Snapshot active_sanction_snapshots one row per synced list
16
+ # Row::Entity active_sanction_entities one row per record on it
17
+ # Row::Name active_sanction_names one row per name variant
18
+ # Row::Address active_sanction_addresses
19
+ # Row::Identifier active_sanction_identifiers
20
+ #
21
+ # They are namespaced under `Row` rather than named `Snapshot`, `Entity`
22
+ # and so on directly, for a reason that is not stylistic: this file sits
23
+ # inside ActiveSanction, where `Snapshot` and `Entity` already mean the
24
+ # canonical value objects. A model shadowing either would make every
25
+ # unqualified reference in this adapter mean whichever one Ruby's lexical
26
+ # lookup reached first. `Row::Snapshot` is the table; `Snapshot` is the
27
+ # record.
28
+ #
29
+ # ### These are public
30
+ #
31
+ # Unlike Storage::FileSystem's directory layout, which is `@api private`
32
+ # under #62, the schema here is the point of the adapter. An installation
33
+ # that already has a database wants to *query* its lists -- to narrow
34
+ # 19,015 OFAC records to the few hundred worth scoring in Ruby, before
35
+ # any of them are loaded:
36
+ #
37
+ # Row::Name.matching("Aiman al-Zawahiri").pluck(:entity_id)
38
+ #
39
+ # So the table names, the columns and the associations are part of what
40
+ # this adapter promises, and change under SemVer like anything else
41
+ # public.
42
+ #
43
+ # ### What they deliberately do not do
44
+ #
45
+ # No validations, no callbacks, no `dependent: :destroy`. Every write
46
+ # goes through `insert_all` in batches inside one transaction, which
47
+ # bypasses all three by design: a sync writes 19,015 entities and some
48
+ # 65,000 rows hanging off them, and row-at-a-time saves with callbacks
49
+ # turn seconds into minutes. Deletion is Row::Snapshot#discard!, which
50
+ # drops the children in bulk and then the parent.
51
+ #
52
+ # @api private
53
+ module Row
54
+ # Abstract, so a host can point the sanctions tables at a database
55
+ # other than its application's without touching ActiveRecord::Base:
56
+ #
57
+ # ActiveSanction::Storage::ActiveRecord::Row::Base.connects_to(database: { writing: :sanctions })
58
+ #
59
+ # Left unconnected here, so it inherits whatever ActiveRecord::Base is
60
+ # connected to -- the right default, because the common case is a Rails
61
+ # application with one database.
62
+ #
63
+ # @api private
64
+ class Base < ::ActiveRecord::Base
65
+ self.abstract_class = true
66
+ end
67
+
68
+ # `active_sanction_snapshots` -- one row per synced list, carrying the
69
+ # checksum the whole list is rebuilt against.
70
+ #
71
+ # @api private
72
+ class Snapshot < Base
73
+ extend T::Sig
74
+
75
+ self.table_name = "active_sanction_snapshots"
76
+
77
+ has_many :entities, class_name: "ActiveSanction::Storage::ActiveRecord::Row::Entity",
78
+ foreign_key: :snapshot_id, inverse_of: :snapshot, dependent: nil
79
+
80
+ # Everything stored for this list, including the row itself, dropped
81
+ # in bulk. `delete_all` rather than `destroy_all`: these rows have no
82
+ # callbacks to run, and instantiating 19,015 of them to throw them
83
+ # away is exactly the cost this adapter exists to avoid.
84
+ #
85
+ # Children first, parent last, so the sequence is one a host that has
86
+ # added foreign keys of its own can also execute.
87
+ sig { void }
88
+ def discard!
89
+ [Name, Address, Identifier, Entity].each { |model| model.where(snapshot_id: id).delete_all }
90
+ self.class.where(id: id).delete_all
91
+ end
92
+ end
93
+
94
+ # `active_sanction_entities` -- one row per record on a list, with its
95
+ # names, addresses and identifiers hanging off it.
96
+ #
97
+ # @api private
98
+ class Entity < Base
99
+ self.table_name = "active_sanction_entities"
100
+
101
+ belongs_to :snapshot, class_name: "ActiveSanction::Storage::ActiveRecord::Row::Snapshot",
102
+ inverse_of: :entities
103
+
104
+ has_many :names, class_name: "ActiveSanction::Storage::ActiveRecord::Row::Name",
105
+ foreign_key: :entity_id, inverse_of: :entity, dependent: nil
106
+ has_many :addresses, class_name: "ActiveSanction::Storage::ActiveRecord::Row::Address",
107
+ foreign_key: :entity_id, inverse_of: :entity, dependent: nil
108
+ has_many :identifiers, class_name: "ActiveSanction::Storage::ActiveRecord::Row::Identifier",
109
+ foreign_key: :entity_id, inverse_of: :entity, dependent: nil
110
+ end
111
+
112
+ # `active_sanction_names` -- one row per name variant, primary or alias.
113
+ # `normalized_value` is the indexed column the prefilter probes.
114
+ #
115
+ # @api private
116
+ class Name < Base
117
+ self.table_name = "active_sanction_names"
118
+
119
+ belongs_to :entity, class_name: "ActiveSanction::Storage::ActiveRecord::Row::Entity",
120
+ inverse_of: :names
121
+
122
+ # The names whose prefilter key is the one this string folds to --
123
+ # the candidate-generation path, and the reason the index exists:
124
+ #
125
+ # Row::Name.matching("Aiman AL-ZAWAHIRI").pluck(:entity_id)
126
+ #
127
+ # An equality lookup on an indexed column, so it stays a b-tree probe
128
+ # rather than the table scan a `LIKE '%...%'` would be. It prefilters
129
+ # and does not match: it answers "which records are worth scoring in
130
+ # Ruby", never "is this the same person".
131
+ scope :matching, lambda { |value|
132
+ where(normalized_value: ActiveSanction::Storage::ActiveRecord.prefilter_key(value))
133
+ }
134
+ end
135
+
136
+ # `active_sanction_addresses` -- one row per published address. Not a
137
+ # prefilter path: addresses on these lists are too partial to probe on.
138
+ #
139
+ # @api private
140
+ class Address < Base
141
+ self.table_name = "active_sanction_addresses"
142
+
143
+ belongs_to :entity, class_name: "ActiveSanction::Storage::ActiveRecord::Row::Entity",
144
+ inverse_of: :addresses
145
+ end
146
+
147
+ # `active_sanction_identifiers` -- one row per document number, keyed on
148
+ # ActiveSanction::Identifier#normalized_value so that two publishers'
149
+ # punctuation of the same passport finds each other.
150
+ #
151
+ # @api private
152
+ class Identifier < Base
153
+ self.table_name = "active_sanction_identifiers"
154
+
155
+ belongs_to :entity, class_name: "ActiveSanction::Storage::ActiveRecord::Row::Entity",
156
+ inverse_of: :identifiers
157
+
158
+ # The other prefilter path, and the stronger one: an exact document
159
+ # match is near-decisive where a name match never is. Keyed on
160
+ # ActiveSanction::Identifier#normalized_value, which is why OFAC's
161
+ # `AB-123 456` and the UN's `AB123456` find each other.
162
+ scope :matching, lambda { |value|
163
+ where(normalized_value: ActiveSanction::Identifier.new(value: value).normalized_value)
164
+ }
165
+ end
166
+
167
+ # Parents before children: the order a write inserts in, and the
168
+ # reverse of the order a delete removes in.
169
+ #
170
+ # @api private
171
+ ALL = T.let([Snapshot, Entity, Name, Address, Identifier].freeze, T::Array[T.untyped])
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,141 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "json"
7
+ require "active_sanction/storage/base"
8
+ require "active_sanction/storage/active_record/row"
9
+
10
+ module ActiveSanction
11
+ module Storage
12
+ class ActiveRecord < Base
13
+ # Turns one Snapshot into rows, in batches, with `insert_all`.
14
+ #
15
+ # Called with the `active_sanction_snapshots` row already created and
16
+ # inside the transaction that created it, which is the arrangement the
17
+ # acceptance criterion asks for: a sync that dies partway through 19,015
18
+ # entities rolls back to the list that was there before it, rather than
19
+ # leaving a snapshot row advertising records that are not underneath it.
20
+ #
21
+ # ### Why the entity ids are read back rather than returned
22
+ #
23
+ # A child row needs its parent's primary key, and `insert_all` can return
24
+ # the keys it assigned -- on PostgreSQL and on recent SQLite. MySQL has
25
+ # no `RETURNING`, and an adapter that worked on two of the three
26
+ # databases Rails ships support for would not be worth the word
27
+ # "optional" in the issue. So the entities go in first, and one ordered
28
+ # `pluck` of their ids follows: one extra query per sync, against a
29
+ # method that works everywhere.
30
+ #
31
+ # @api private
32
+ class Writer
33
+ extend T::Sig
34
+
35
+ sig { params(row: T.untyped, snapshot: Snapshot, batch_size: Integer).void }
36
+ def initialize(row, snapshot, batch_size:)
37
+ @row = T.let(row, T.untyped)
38
+ @snapshot = T.let(snapshot, Snapshot)
39
+ @batch_size = T.let(batch_size, Integer)
40
+ end
41
+
42
+ sig { returns(T.untyped) }
43
+ def call
44
+ write_entities
45
+ write_children
46
+ @row
47
+ end
48
+
49
+ private
50
+
51
+ sig { void }
52
+ def write_entities
53
+ each_batch { |pairs| insert(Row::Entity, pairs.map { |entity, position| entity_row(entity, position) }) }
54
+ end
55
+
56
+ sig { void }
57
+ def write_children
58
+ ids = entity_ids
59
+ each_batch { |pairs| write_batch(pairs, ids) }
60
+ end
61
+
62
+ sig { params(pairs: T::Array[T.untyped], ids: T::Array[T.untyped]).void }
63
+ def write_batch(pairs, ids)
64
+ insert(Row::Name, pairs.flat_map { |entity, index| name_rows(entity, ids.fetch(index)) })
65
+ insert(Row::Address, pairs.flat_map { |entity, index| address_rows(entity, ids.fetch(index)) })
66
+ insert(Row::Identifier, pairs.flat_map { |entity, index| identifier_rows(entity, ids.fetch(index)) })
67
+ end
68
+
69
+ # The list in slices, each entity paired with the position it was
70
+ # written at -- which is the index its primary key sits at in `ids`.
71
+ sig { params(block: T.proc.params(pairs: T::Array[T.untyped]).void).void }
72
+ def each_batch(&block) = @snapshot.entities.each_with_index.each_slice(@batch_size, &block)
73
+
74
+ # Ordered by the column the writer just filled in, so the id at index
75
+ # `n` belongs to the entity written at position `n`.
76
+ sig { returns(T::Array[T.untyped]) }
77
+ def entity_ids = Row::Entity.where(snapshot_id: @row.id).order(:position).pluck(:id)
78
+
79
+ sig { params(model: T.untyped, rows: T::Array[T::Hash[Symbol, T.untyped]]).void }
80
+ def insert(model, rows)
81
+ rows.each_slice(@batch_size) { |slice| model.insert_all(slice) }
82
+ end
83
+
84
+ sig { params(entity: Entity, position: Integer).returns(T::Hash[Symbol, T.untyped]) }
85
+ def entity_row(entity, position)
86
+ {
87
+ snapshot_id: @row.id, position: position, external_id: entity.id, source: entity.source.to_s,
88
+ entity_type: entity.type.to_s, source_ref: entity.source_ref,
89
+ dates_of_birth: json(entity.dates_of_birth.map(&:to_h)), nationalities: json(entity.nationalities),
90
+ programs: json(entity.programs), listed_on: json(entity.listed_on&.to_h), remarks: entity.remarks
91
+ }
92
+ end
93
+
94
+ sig { params(entity: Entity, entity_id: T.untyped).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
95
+ def name_rows(entity, entity_id)
96
+ entity.names.each_with_index.map do |name, position|
97
+ {
98
+ entity_id: entity_id, snapshot_id: @row.id, position: position, value: name.value,
99
+ normalized_value: ActiveSanction::Storage::ActiveRecord.prefilter_key(name.value),
100
+ kind: name.kind.to_s, quality: name.quality&.to_s, script: name.script&.to_s
101
+ }
102
+ end
103
+ end
104
+
105
+ sig { params(entity: Entity, entity_id: T.untyped).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
106
+ def address_rows(entity, entity_id)
107
+ entity.addresses.each_with_index.map do |address, position|
108
+ {
109
+ entity_id: entity_id, snapshot_id: @row.id, position: position, street: address.street,
110
+ city: address.city, state_province: address.state_province, postal_code: address.postal_code,
111
+ country: address.country, note: address.note
112
+ }
113
+ end
114
+ end
115
+
116
+ sig { params(entity: Entity, entity_id: T.untyped).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
117
+ def identifier_rows(entity, entity_id)
118
+ entity.identifiers.each_with_index.map do |identifier, position|
119
+ {
120
+ entity_id: entity_id, snapshot_id: @row.id, position: position, kind: identifier.kind.to_s,
121
+ value: identifier.value, normalized_value: identifier.normalized_value, country: identifier.country,
122
+ issued_on: json(identifier.issued_on&.to_h), expires_on: json(identifier.expires_on&.to_h),
123
+ note: identifier.note
124
+ }
125
+ end
126
+ end
127
+
128
+ # Nil rather than "[]" or "null" for anything the publisher did not
129
+ # give, which keeps the column readable in a database console and
130
+ # matches what the canonical record means by an absent member: Entity
131
+ # reads a nil collection back as an empty one.
132
+ sig { params(value: T.untyped).returns(T.nilable(String)) }
133
+ def json(value)
134
+ return nil if value.nil? || (value.respond_to?(:empty?) && value.empty?)
135
+
136
+ JSON.generate(value)
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end