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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- metadata +236 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_record"
|
|
7
|
+
require "json"
|
|
8
|
+
require "active_sanction/configuration"
|
|
9
|
+
require "active_sanction/snapshot"
|
|
10
|
+
require "active_sanction/storage"
|
|
11
|
+
require "active_sanction/storage/base"
|
|
12
|
+
require "active_sanction/storage/meta"
|
|
13
|
+
require "active_sanction/storage/active_record/row"
|
|
14
|
+
require "active_sanction/storage/active_record/reader"
|
|
15
|
+
require "active_sanction/storage/active_record/writer"
|
|
16
|
+
|
|
17
|
+
module ActiveSanction
|
|
18
|
+
module Storage
|
|
19
|
+
# Snapshots in the host application's database.
|
|
20
|
+
#
|
|
21
|
+
# $ rails generate active_sanction:install && rails db:migrate
|
|
22
|
+
#
|
|
23
|
+
# store = ActiveSanction::Storage::ActiveRecord.new
|
|
24
|
+
# store.write_snapshot(ActiveSanction::Sources[:ofac_sdn].new.sync)
|
|
25
|
+
# store.snapshot_meta(:ofac_sdn).age # one row read
|
|
26
|
+
#
|
|
27
|
+
# ### Optional means optional
|
|
28
|
+
#
|
|
29
|
+
# ActiveRecord is not a dependency of this gem and must not become one.
|
|
30
|
+
# Nothing requires this file unless a host has already loaded ActiveRecord
|
|
31
|
+
# itself -- see the guard at the bottom of storage.rb -- and the gem is
|
|
32
|
+
# fully usable, including Storage::FileSystem, with ActiveRecord absent.
|
|
33
|
+
# The default adapter costs a directory; this one costs a migration, and it
|
|
34
|
+
# is for an installation that already has a database and wants to *query*
|
|
35
|
+
# its lists, not a prerequisite for using this library.
|
|
36
|
+
#
|
|
37
|
+
# ### What the database buys, and what it does not
|
|
38
|
+
#
|
|
39
|
+
# It buys the prefilter. Scoring 19,015 OFAC records against one name in
|
|
40
|
+
# Ruby is the cost the matcher (#33) wants to avoid paying, and an equality
|
|
41
|
+
# probe on an indexed column narrows that to a few hundred candidates
|
|
42
|
+
# before any of them are loaded:
|
|
43
|
+
#
|
|
44
|
+
# Row::Name.matching("Aiman al-Zawahiri").pluck(:entity_id)
|
|
45
|
+
# Row::Identifier.matching("AB-123 456").pluck(:entity_id)
|
|
46
|
+
#
|
|
47
|
+
# It does not buy a different reading model. `each_entity` is inherited
|
|
48
|
+
# from Storage::Base rather than reimplemented as a cursor, and that is a
|
|
49
|
+
# decision rather than an omission: a snapshot's checksum is computed over
|
|
50
|
+
# the whole list, so a store that streamed rows straight to the matcher
|
|
51
|
+
# would be handing it records it cannot prove are all of them. Reading a
|
|
52
|
+
# list here materializes it and verifies it, exactly as the other adapters
|
|
53
|
+
# do. Tens of megabytes is a fine price for that; screening against a list
|
|
54
|
+
# that is quietly missing people is not.
|
|
55
|
+
#
|
|
56
|
+
# ### Writing
|
|
57
|
+
#
|
|
58
|
+
# One transaction per list, and `insert_all` in batches inside it. A sync
|
|
59
|
+
# that dies partway through 19,015 entities rolls back to the list that was
|
|
60
|
+
# there before it -- there is no half-updated state to inspect, and none to
|
|
61
|
+
# screen against. Row-at-a-time saves would be the obvious alternative and
|
|
62
|
+
# are not: 19,015 entities plus some 65,000 rows hanging off them is not
|
|
63
|
+
# work to do one `INSERT` at a time.
|
|
64
|
+
#
|
|
65
|
+
# ### Reading
|
|
66
|
+
#
|
|
67
|
+
# Nothing partial is ever returned. The Snapshot is rebuilt with the
|
|
68
|
+
# checksum stored beside it, so construction re-derives the digest over the
|
|
69
|
+
# records that actually came back and raises CorruptSnapshot when they do
|
|
70
|
+
# not agree -- a row deleted by hand, a write that half landed, a column
|
|
71
|
+
# edited in a console. A schema_version this gem does not know raises
|
|
72
|
+
# UnsupportedSchema before a record is read.
|
|
73
|
+
#
|
|
74
|
+
# ### Concurrency
|
|
75
|
+
#
|
|
76
|
+
# The database's problem, which is the point. A write is one transaction,
|
|
77
|
+
# so a reader sees the list as it was before it or as it is after it, and
|
|
78
|
+
# readers on other processes and other machines get that for free rather
|
|
79
|
+
# than from a rename that only holds within one filesystem.
|
|
80
|
+
class ActiveRecord < Base
|
|
81
|
+
extend T::Sig
|
|
82
|
+
|
|
83
|
+
# Snapshot versions this code can read. Anything above the version it
|
|
84
|
+
# writes was produced by a newer gem.
|
|
85
|
+
#
|
|
86
|
+
# @api private
|
|
87
|
+
READABLE_SCHEMA_VERSIONS = T.let(1..Snapshot::SCHEMA_VERSION, T::Range[Integer])
|
|
88
|
+
|
|
89
|
+
# Rows per `insert_all`. Big enough that a full OFAC sync is a few dozen
|
|
90
|
+
# statements rather than 19,015, small enough that no single statement is
|
|
91
|
+
# megabytes of SQL a database has to parse in one piece.
|
|
92
|
+
#
|
|
93
|
+
# @api private
|
|
94
|
+
DEFAULT_BATCH_SIZE = T.let(1_000, Integer)
|
|
95
|
+
|
|
96
|
+
# What `normalized_value` is declared as, because it is indexed and MySQL
|
|
97
|
+
# will not index an unbounded column. Comfortably past the longest name
|
|
98
|
+
# any of the launch lists publishes.
|
|
99
|
+
#
|
|
100
|
+
# @api private
|
|
101
|
+
PREFILTER_KEY_LIMIT = T.let(512, Integer)
|
|
102
|
+
|
|
103
|
+
# @api private
|
|
104
|
+
COMBINING_MARKS = T.let(/\p{Mn}+/, Regexp)
|
|
105
|
+
# @api private
|
|
106
|
+
NON_ALPHANUMERIC = T.let(/[^[:alnum:]]+/, Regexp)
|
|
107
|
+
|
|
108
|
+
# The key a name is filed under in `active_sanction_names.normalized_value`
|
|
109
|
+
# and the key a query has to build to find it:
|
|
110
|
+
#
|
|
111
|
+
# prefilter_key("Aiman al-ZAWAHIRI!") # => "aiman al zawahiri"
|
|
112
|
+
# prefilter_key("Ayman al-Ẓawāhirī") # => "ayman al zawahiri"
|
|
113
|
+
#
|
|
114
|
+
# Deliberately crude, and deliberately not the matcher's normalizer
|
|
115
|
+
# (#26). Its only job is candidate generation, where the cost of the two
|
|
116
|
+
# kinds of error is wildly asymmetric: a key that collides too eagerly
|
|
117
|
+
# costs a few extra records to score in Ruby, and a key that misses costs
|
|
118
|
+
# a sanctioned person who never reaches the scorer at all. So it folds
|
|
119
|
+
# width and diacritics, cases down, and reduces everything that is not
|
|
120
|
+
# alphanumeric to a single space -- and it stops there. It does not
|
|
121
|
+
# transliterate, drop legal forms (#27), or reorder tokens; those change
|
|
122
|
+
# what a name *means* and belong where a human can see the decision.
|
|
123
|
+
#
|
|
124
|
+
# Because it is stored, changing this fold makes the stored keys stale.
|
|
125
|
+
# A release that changes it will say so, and the fix is a re-sync.
|
|
126
|
+
sig { params(value: T.untyped).returns(String) }
|
|
127
|
+
def self.prefilter_key(value)
|
|
128
|
+
folded = value.to_s.unicode_normalize(:nfkd).gsub(COMBINING_MARKS, "").downcase
|
|
129
|
+
folded.gsub(NON_ALPHANUMERIC, " ").strip.squeeze(" ").slice(0, PREFILTER_KEY_LIMIT).to_s
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Whether the migration has been run. Not checked on construction: an
|
|
133
|
+
# adapter built in a Rails initializer must not open a connection to say
|
|
134
|
+
# hello, and a host running `rails db:migrate` would then be unable to
|
|
135
|
+
# boot the application that migrates it.
|
|
136
|
+
sig { returns(T::Boolean) }
|
|
137
|
+
def self.installed?
|
|
138
|
+
Row::ALL.all?(&:table_exists?)
|
|
139
|
+
rescue ::ActiveRecord::ActiveRecordError
|
|
140
|
+
false
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Rows per `insert_all` -- see DEFAULT_BATCH_SIZE.
|
|
144
|
+
sig { returns(Integer) }
|
|
145
|
+
attr_reader :batch_size
|
|
146
|
+
|
|
147
|
+
sig { params(batch_size: T.untyped).void }
|
|
148
|
+
def initialize(batch_size: DEFAULT_BATCH_SIZE)
|
|
149
|
+
@batch_size = T.let(batch_size!(batch_size), Integer)
|
|
150
|
+
super()
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Replaces the source's list inside one transaction: the previous
|
|
154
|
+
# generation is dropped and the new one written, or neither happens.
|
|
155
|
+
sig { override.params(snapshot: T.untyped).returns(Snapshot) }
|
|
156
|
+
def write_snapshot(snapshot)
|
|
157
|
+
stored = snapshot!(snapshot)
|
|
158
|
+
key = source_key!(stored.source)
|
|
159
|
+
connected do
|
|
160
|
+
Row::Base.transaction do
|
|
161
|
+
Row::Snapshot.find_by(source: key.to_s)&.discard!
|
|
162
|
+
Writer.new(create_row(key, stored), stored, batch_size: batch_size).call
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
stored
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
sig { override.params(source: T.untyped).returns(T.nilable(Snapshot)) }
|
|
169
|
+
def read_snapshot(source)
|
|
170
|
+
row = snapshot_row(source_key!(source))
|
|
171
|
+
return nil if row.nil?
|
|
172
|
+
|
|
173
|
+
schema_version!(row)
|
|
174
|
+
build(row)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# One row read, and no entities. What makes printing how old six lists
|
|
178
|
+
# are six primary-key lookups rather than six full deserializations.
|
|
179
|
+
sig { override.params(source: T.untyped).returns(T.nilable(Meta)) }
|
|
180
|
+
def snapshot_meta(source)
|
|
181
|
+
row = snapshot_row(source_key!(source))
|
|
182
|
+
return nil if row.nil?
|
|
183
|
+
|
|
184
|
+
Meta.new(source: row.source, fetched_at: row.fetched_at.to_time, checksum: row.checksum,
|
|
185
|
+
record_count: row.record_count, schema_version: row.schema_version,
|
|
186
|
+
source_version: row.source_version)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
sig { override.params(source: T.untyped).returns(T::Boolean) }
|
|
190
|
+
def delete_snapshot(source)
|
|
191
|
+
key = source_key!(source)
|
|
192
|
+
connected do
|
|
193
|
+
Row::Base.transaction do
|
|
194
|
+
row = Row::Snapshot.find_by(source: key.to_s)
|
|
195
|
+
row&.discard!
|
|
196
|
+
!row.nil?
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Sorted in Ruby rather than by the database, so a summary does not
|
|
202
|
+
# reshuffle itself when the same lists are read through a connection with
|
|
203
|
+
# a different collation.
|
|
204
|
+
sig { override.returns(T::Array[Symbol]) }
|
|
205
|
+
def sources = connected { Row::Snapshot.pluck(:source) }.map(&:to_sym).sort
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
sig { params(key: Symbol).returns(T.untyped) }
|
|
210
|
+
def snapshot_row(key) = connected { Row::Snapshot.find_by(source: key.to_s) }
|
|
211
|
+
|
|
212
|
+
sig { params(key: Symbol, snapshot: Snapshot).returns(T.untyped) }
|
|
213
|
+
def create_row(key, snapshot)
|
|
214
|
+
Row::Snapshot.create!(source: key.to_s, fetched_at: snapshot.fetched_at, checksum: snapshot.checksum,
|
|
215
|
+
record_count: snapshot.record_count, schema_version: snapshot.schema_version,
|
|
216
|
+
source_version: snapshot.source_version)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Snapshot recomputes the checksum over the records that came back and
|
|
220
|
+
# refuses to build if it does not match the one stored beside them, which
|
|
221
|
+
# is what turns every way of losing a row into an exception rather than
|
|
222
|
+
# into a clean report.
|
|
223
|
+
sig { params(row: T.untyped).returns(Snapshot) }
|
|
224
|
+
def build(row)
|
|
225
|
+
Reader.new(row).call
|
|
226
|
+
rescue Snapshot::ChecksumMismatch, ArgumentError, TypeError, JSON::ParserError => e
|
|
227
|
+
raise CorruptSnapshot, corrupt(row, e.message)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Checked before a record is read, because a snapshot written by a newer
|
|
231
|
+
# gem will usually still deserialize -- into records missing whatever the
|
|
232
|
+
# new version added, with no symptom other than names that stop matching.
|
|
233
|
+
sig { params(row: T.untyped).void }
|
|
234
|
+
def schema_version!(row)
|
|
235
|
+
return if READABLE_SCHEMA_VERSIONS.cover?(row.schema_version)
|
|
236
|
+
|
|
237
|
+
raise UnsupportedSchema,
|
|
238
|
+
"the stored #{row.source} list was written under snapshot schema_version " \
|
|
239
|
+
"#{row.schema_version.inspect}; active_sanction #{VERSION} reads " \
|
|
240
|
+
"#{READABLE_SCHEMA_VERSIONS.first}-#{READABLE_SCHEMA_VERSIONS.last}. Upgrade the gem, or delete the " \
|
|
241
|
+
"snapshot and re-sync the source."
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# An un-migrated database is a misconfigured installation, not a corrupt
|
|
245
|
+
# list, and it has a different fix -- so it is worth saying which one it
|
|
246
|
+
# is rather than letting a bare `no such table` reach the caller.
|
|
247
|
+
sig { params(block: T.proc.returns(T.untyped)).returns(T.untyped) }
|
|
248
|
+
def connected(&block)
|
|
249
|
+
block.call
|
|
250
|
+
rescue ::ActiveRecord::StatementInvalid
|
|
251
|
+
raise if self.class.installed?
|
|
252
|
+
|
|
253
|
+
raise ConfigurationError,
|
|
254
|
+
"the ActiveSanction storage tables are not in this database. Run " \
|
|
255
|
+
"`rails generate active_sanction:install && rails db:migrate`, or use " \
|
|
256
|
+
"ActiveSanction::Storage::FileSystem, which needs no schema."
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
sig { params(value: T.untyped).returns(Integer) }
|
|
260
|
+
def batch_size!(value)
|
|
261
|
+
# `exception: false` answers nil for anything unparseable, which the
|
|
262
|
+
# stdlib RBI does not say -- hence the nilable annotation.
|
|
263
|
+
integer = T.let(Integer(value, exception: false), T.nilable(Integer))
|
|
264
|
+
raise ConfigurationError, "batch_size must be a whole number of rows, got #{value.inspect}" if integer.nil?
|
|
265
|
+
raise ConfigurationError, "batch_size must be at least 1, got #{integer}" unless integer.positive?
|
|
266
|
+
|
|
267
|
+
integer
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
sig { params(row: T.untyped, detail: String).returns(String) }
|
|
271
|
+
def corrupt(row, detail)
|
|
272
|
+
"the stored #{row.source} list cannot be trusted to be the list it says it is (#{detail}). Nothing partial " \
|
|
273
|
+
"is returned from storage -- delete the snapshot and re-sync the source to replace it."
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/error"
|
|
7
|
+
require "active_sanction/snapshot"
|
|
8
|
+
require "active_sanction/sources"
|
|
9
|
+
require "active_sanction/storage"
|
|
10
|
+
require "active_sanction/storage/meta"
|
|
11
|
+
|
|
12
|
+
module ActiveSanction
|
|
13
|
+
module Storage
|
|
14
|
+
# The persistence contract: one snapshot per source, written whole and read
|
|
15
|
+
# back whole.
|
|
16
|
+
#
|
|
17
|
+
# store = ActiveSanction::Storage::Memory.new
|
|
18
|
+
# store.write_snapshot(ActiveSanction::Sources[:ofac_sdn].new.sync)
|
|
19
|
+
#
|
|
20
|
+
# store.sources # => [:ofac_sdn]
|
|
21
|
+
# store.read_snapshot(:ofac_sdn) # => Snapshot, or nil
|
|
22
|
+
# store.snapshot_meta(:ofac_sdn) # => Meta: fetched_at, checksum, record_count
|
|
23
|
+
# store.each_entity { |entity| ... }
|
|
24
|
+
#
|
|
25
|
+
# ### The rule this class exists to enforce
|
|
26
|
+
#
|
|
27
|
+
# **Nothing on the query path may name a concrete store.** The matcher
|
|
28
|
+
# (#33) is written against these five methods and against nothing else,
|
|
29
|
+
# which is what lets an installation put its lists in gzipped JSON, in
|
|
30
|
+
# Postgres, or in a store it wrote itself without any of that reaching the
|
|
31
|
+
# code that decides whether two names are the same person. It is also why
|
|
32
|
+
# this interface lands before the matcher rather than after it: an
|
|
33
|
+
# interface extracted from a matcher that already reads files is an
|
|
34
|
+
# interface shaped like files.
|
|
35
|
+
#
|
|
36
|
+
# ### What an adapter implements
|
|
37
|
+
#
|
|
38
|
+
# Four methods, and they are deliberately coarse:
|
|
39
|
+
#
|
|
40
|
+
# write_snapshot(snapshot) # replace this source's list
|
|
41
|
+
# read_snapshot(source) # => Snapshot, or nil if never synced
|
|
42
|
+
# delete_snapshot(source) # => true if there was one
|
|
43
|
+
# sources # => [:ofac_sdn, ...], sorted
|
|
44
|
+
#
|
|
45
|
+
# A snapshot is the unit because it is the unit that carries a checksum. A
|
|
46
|
+
# store that wrote entities one at a time could leave a list half-replaced
|
|
47
|
+
# -- 8,000 of OFAC's 19,015 -- that still reads back as a valid list, and
|
|
48
|
+
# nothing downstream would be able to tell. Both planned adapters can honour
|
|
49
|
+
# that: #24 writes to a temporary file and renames it, #25 wraps the upsert
|
|
50
|
+
# in a transaction. Neither needs a finer-grained interface to do it.
|
|
51
|
+
#
|
|
52
|
+
# Everything else here is derived from those four and inherited, so an
|
|
53
|
+
# adapter that implements them gets the rest right by default. An adapter
|
|
54
|
+
# that can answer one of them better should override it -- `snapshot_meta`
|
|
55
|
+
# off a sidecar file rather than by reading the list, `each_entity` off a
|
|
56
|
+
# cursor rather than by materializing a snapshot -- and the shared
|
|
57
|
+
# conformance group ("a storage adapter") is what holds an override to
|
|
58
|
+
# meaning the same thing.
|
|
59
|
+
class Base
|
|
60
|
+
extend T::Sig
|
|
61
|
+
|
|
62
|
+
# Replaces everything stored for `snapshot.source` and returns the
|
|
63
|
+
# snapshot. One source at a time: a sync isolates its lists from each
|
|
64
|
+
# other (#34), so a write must not be able to disturb a list it was not
|
|
65
|
+
# given.
|
|
66
|
+
sig { params(_snapshot: T.untyped).returns(Snapshot) }
|
|
67
|
+
def write_snapshot(_snapshot)
|
|
68
|
+
raise UnsupportedError, "#{self.class} must implement #write_snapshot(snapshot)"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The stored snapshot, or nil when the source has never been synced.
|
|
72
|
+
#
|
|
73
|
+
# Nil is the honest answer to "have we ever synced this?" and callers
|
|
74
|
+
# that cannot proceed without a list should ask through #fetch_snapshot,
|
|
75
|
+
# which raises. What an adapter must never do is answer an empty snapshot:
|
|
76
|
+
# "nothing was ever fetched" and "this list has nobody on it" are
|
|
77
|
+
# different states, and only one of them is safe to screen against.
|
|
78
|
+
sig { params(_source: T.untyped).returns(T.nilable(Snapshot)) }
|
|
79
|
+
def read_snapshot(_source)
|
|
80
|
+
raise UnsupportedError, "#{self.class} must implement #read_snapshot(source)"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Drops a source's snapshot and returns whether there was one to drop.
|
|
84
|
+
# Deleting a source that was never stored is not an error: it is the
|
|
85
|
+
# state the caller asked for.
|
|
86
|
+
sig { params(_source: T.untyped).returns(T::Boolean) }
|
|
87
|
+
def delete_snapshot(_source)
|
|
88
|
+
raise UnsupportedError, "#{self.class} must implement #delete_snapshot(source)"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Every source with a stored snapshot, sorted, so a CLI listing and a
|
|
92
|
+
# sync summary do not reshuffle themselves between runs.
|
|
93
|
+
sig { returns(T::Array[Symbol]) }
|
|
94
|
+
def sources
|
|
95
|
+
raise UnsupportedError, "#{self.class} must implement #sources"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# What is stored for a source without reading the list: fetched_at,
|
|
99
|
+
# checksum, record_count. Nil when nothing is stored.
|
|
100
|
+
#
|
|
101
|
+
# Derived here by reading the snapshot, which is correct but is the thing
|
|
102
|
+
# Meta exists to avoid. An adapter that keeps this separately -- #24's
|
|
103
|
+
# `meta.json` sidecar, a metadata row -- overrides it and answers without
|
|
104
|
+
# deserializing tens of megabytes to print an age.
|
|
105
|
+
sig { params(source: T.untyped).returns(T.nilable(Meta)) }
|
|
106
|
+
def snapshot_meta(source)
|
|
107
|
+
snapshot = read_snapshot(source)
|
|
108
|
+
snapshot && Meta.from_snapshot(snapshot)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The stored snapshot, raising when there is none. For a caller that
|
|
112
|
+
# named the source itself and cannot do its job without it -- screening
|
|
113
|
+
# against a list that is not there returns a clean report, which is the
|
|
114
|
+
# most expensive thing this library can get wrong.
|
|
115
|
+
sig { params(source: T.untyped).returns(Snapshot) }
|
|
116
|
+
def fetch_snapshot(source)
|
|
117
|
+
key = source_key!(source)
|
|
118
|
+
read_snapshot(key) || raise(MissingSnapshot.new(missing_message(key), source_id: key))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Every entity from every stored list, or from the ones named:
|
|
122
|
+
#
|
|
123
|
+
# store.each_entity { |entity| index.add(entity) }
|
|
124
|
+
# store.each_entity(sources: %i[ofac_sdn]).lazy.select { |e| e.type == :vessel }
|
|
125
|
+
#
|
|
126
|
+
# An Enumerator without a block, and it streams: the index build (#31)
|
|
127
|
+
# walks every entity of every list, and materializing an array of ~25,000
|
|
128
|
+
# entities across every source before the first one is yielded is a cost
|
|
129
|
+
# nothing here needs to pay. Snapshots are read one at a time and each is
|
|
130
|
+
# released before the next is opened.
|
|
131
|
+
#
|
|
132
|
+
# Naming sources changes what a missing one means. `sources: nil` asks
|
|
133
|
+
# for whatever is stored, where there is nothing to be missing; naming a
|
|
134
|
+
# list that has never been synced raises MissingSnapshot rather than
|
|
135
|
+
# yielding fewer entities, because a screening run that quietly covers
|
|
136
|
+
# two of the three lists it was configured with is indistinguishable from
|
|
137
|
+
# one that covers all three.
|
|
138
|
+
sig do
|
|
139
|
+
params(sources: T.untyped, block: T.nilable(T.proc.params(entity: T.untyped).void)).returns(T.untyped)
|
|
140
|
+
end
|
|
141
|
+
def each_entity(sources: nil, &block)
|
|
142
|
+
return enum_for(:each_entity, sources: sources) unless block
|
|
143
|
+
|
|
144
|
+
each_snapshot(sources) { |snapshot| snapshot.entities.each(&block) }
|
|
145
|
+
self
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Whether a source has a stored snapshot. Answered off #sources rather
|
|
149
|
+
# than by reading one, so asking is cheap for every adapter.
|
|
150
|
+
sig { params(source: T.untyped).returns(T::Boolean) }
|
|
151
|
+
def stored?(source) = sources.include?(source_key!(source))
|
|
152
|
+
|
|
153
|
+
sig { returns(Integer) }
|
|
154
|
+
def size = sources.size
|
|
155
|
+
|
|
156
|
+
sig { returns(T::Boolean) }
|
|
157
|
+
def empty? = sources.empty?
|
|
158
|
+
|
|
159
|
+
# Drops every snapshot and returns the store. Deliberately spelled out
|
|
160
|
+
# rather than implemented as a truncation, so an adapter only ever has to
|
|
161
|
+
# get one deletion path right.
|
|
162
|
+
sig { returns(T.self_type) }
|
|
163
|
+
def clear
|
|
164
|
+
sources.each { |source| delete_snapshot(source) }
|
|
165
|
+
self
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
sig { returns(String) }
|
|
169
|
+
def inspect = "#<#{self.class} #{list}>"
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# The snapshots a caller asked for, in the order they were asked for.
|
|
174
|
+
sig { params(named: T.untyped, block: T.proc.params(snapshot: Snapshot).void).void }
|
|
175
|
+
def each_snapshot(named, &block)
|
|
176
|
+
return Array(named).each { |source| block.call(fetch_snapshot(source)) } unless named.nil?
|
|
177
|
+
|
|
178
|
+
sources.each do |source|
|
|
179
|
+
snapshot = read_snapshot(source)
|
|
180
|
+
block.call(snapshot) if snapshot
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The same rule the registry holds a source key to, applied here for the
|
|
185
|
+
# reason that rule exists: a key is typed by a human -- into
|
|
186
|
+
# configuration, into a CLI argument -- and it is a directory name to
|
|
187
|
+
# every adapter that writes files. Sharing it means #24 cannot be handed
|
|
188
|
+
# a key that escapes its root, and it means a name that is not a source
|
|
189
|
+
# fails the same way wherever it is typed.
|
|
190
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
191
|
+
def source_key!(value) = Sources::Definition.key!(value)
|
|
192
|
+
|
|
193
|
+
# A Snapshot and not merely something snapshot-shaped. What makes a
|
|
194
|
+
# stored list auditable is that its checksum was computed over its own
|
|
195
|
+
# content by the class that knows how; a hash of the right shape carries
|
|
196
|
+
# a checksum somebody typed.
|
|
197
|
+
sig { params(value: T.untyped).returns(Snapshot) }
|
|
198
|
+
def snapshot!(value)
|
|
199
|
+
unless value.is_a?(Snapshot)
|
|
200
|
+
raise InvalidArgument, "write_snapshot takes an ActiveSanction::Snapshot, got #{value.class}"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
value
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
sig { params(key: Symbol).returns(String) }
|
|
207
|
+
def missing_message(key)
|
|
208
|
+
"no snapshot stored for #{key.inspect}. Stored: #{list}. A source has to be synced before it can be " \
|
|
209
|
+
"screened against -- ActiveSanction::Sources[#{key.inspect}].new.sync"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
sig { returns(String) }
|
|
213
|
+
def list = empty? ? "(nothing)" : sources.join(", ")
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|