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,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
|