Class: ActiveSanction::Parsers::Spreadsheet
- Inherits:
-
Object
- Object
- ActiveSanction::Parsers::Spreadsheet
- Extended by:
- T::Sig
- Defined in:
- lib/active_sanction/parsers/spreadsheet.rb,
lib/active_sanction/parsers/spreadsheet/row.rb,
lib/active_sanction/parsers/spreadsheet/reader.rb,
lib/active_sanction/parsers/spreadsheet/archive.rb,
lib/active_sanction/parsers/spreadsheet/workbook.rb
Overview
Reads an Office Open XML workbook -- an .xlsx file -- into rows an
adapter can map onto Entities.
A table is a description of the file, built once and reused for every sync; a Reader is one pass over one payload.
LIST = ActiveSanction::Parsers::Spreadsheet.new(sheet: "Consolidated List")
LIST.read(bytes).each { |row| row[:name_of_individual_or_entity] }
With no dependency, which was the point
Australia publishes its Consolidated List as a spreadsheet and as nothing else -- no CSV, no XML, no JSON -- so reading it is the price of screening against Australian sanctions at all. The alternative was a spreadsheet gem, which would have been this library's first third-party dependency taken on one publisher's behalf, in a gem whose stated rule is that a compliance library should not be the reason a deployment installs something.
It turned out not to cost much. An .xlsx is a ZIP of XML parts; zlib
is in the standard library and this gem already reads XML, so what was
actually missing was a ZIP header unpacker (Archive) and the two lookups
that make a cell mean something (Workbook). Everything below that is the
XML toolkit the other five adapters use.
What it reads, and what it does not
One sheet of cell values, as strings. Dates are rendered ISO 8601 at the
precision the cell's own format displays -- see Workbook -- so that
PartialDate::Parser reads them without an adapter writing a format.
Formulas are not evaluated: a formula cell is read as the value last
cached in it, which is what a publisher's export contains and what the
file displays. Merged cells, comments, charts, styling and every other
thing a spreadsheet can hold are ignored, because none of them is data on
a sanctions list. Only .xlsx is read, not the older binary .xls --
they share a file extension in conversation and nothing at all in format.
Columns, and why declaring them is optional here
A published spreadsheet has a header row, unlike OFAC's CSVs, so the first
row of the sheet is always the header and never a record. By default its
cells are what the columns are named. Declaring columns: instead renames
them by position, which pins the sheet's shape for a publisher who has
form for re-labelling things -- the header is still consumed, because it
is still a header.
Defined Under Namespace
Classes: Row
Instance Attribute Summary collapse
-
#columns ⇒ Array<Symbol>?
readonly
nil where the sheet names its own columns -- see #headers?.
-
#encoding ⇒ Encoding
readonly
Always UTF-8, and not a caller's choice: the parts of a workbook are XML documents that declare their own encoding, and every writer emits UTF-8.
- #nulls ⇒ Array<String> readonly
-
#sheet ⇒ T.untyped
readonly
The sheet to read: a name, a zero-based index, or nil for the first one.
Instance Method Summary collapse
-
#coerce(names, cells) ⇒ Hash{Symbol => String, nil}
Zips a row's cells against the column names by position.
-
#headers? ⇒ Boolean
Whether the sheet names its own columns.
- #initialize(columns: nil, null: nil, sheet: nil) ⇒ void constructor
- #inspect ⇒ String
-
#read(payload) ⇒ Reader
A pass over one payload.
-
#sheet_name ⇒ String
What to call the sheet being read, for a message: the name or index the caller asked for, or what "the first one" means when they asked for nothing.
-
#unescape(text) ⇒ String?
A cell's text with Excel's escapes resolved.
Constructor Details
#initialize(columns: nil, null: nil, sheet: nil) ⇒ void
91 92 93 94 95 96 97 |
# File 'lib/active_sanction/parsers/spreadsheet.rb', line 91 def initialize(columns: nil, null: nil, sheet: nil) @columns = T.let(columns!(columns), T.nilable(T::Array[Symbol])) @nulls = T.let(nulls!(null), T::Array[String]) @sheet = T.let(sheet, T.untyped) @encoding = T.let(DEFAULT_ENCODING, Encoding) freeze end |
Instance Attribute Details
#columns ⇒ Array<Symbol>? (readonly)
nil where the sheet names its own columns -- see #headers?.
76 77 78 |
# File 'lib/active_sanction/parsers/spreadsheet.rb', line 76 def columns @columns end |
#encoding ⇒ Encoding (readonly)
Always UTF-8, and not a caller's choice: the parts of a workbook are XML documents that declare their own encoding, and every writer emits UTF-8.
88 89 90 |
# File 'lib/active_sanction/parsers/spreadsheet.rb', line 88 def encoding @encoding end |
#nulls ⇒ Array<String> (readonly)
83 84 85 |
# File 'lib/active_sanction/parsers/spreadsheet.rb', line 83 def nulls @nulls end |
#sheet ⇒ T.untyped (readonly)
The sheet to read: a name, a zero-based index, or nil for the first one.
80 81 82 |
# File 'lib/active_sanction/parsers/spreadsheet.rb', line 80 def sheet @sheet end |
Instance Method Details
#coerce(names, cells) ⇒ Hash{Symbol => String, nil}
Zips a row's cells against the column names by position. A column the row left empty is nil, and a cell past the last named column is dropped -- the Reader has already warned about the second.
135 136 137 |
# File 'lib/active_sanction/parsers/spreadsheet.rb', line 135 def coerce(names, cells) names.each_with_index.to_h { |name, index| [name, cells[index]] }.freeze end |
#headers? ⇒ Boolean
Whether the sheet names its own columns.
116 |
# File 'lib/active_sanction/parsers/spreadsheet.rb', line 116 def headers? = columns.nil? |
#inspect ⇒ String
140 141 142 143 144 |
# File 'lib/active_sanction/parsers/spreadsheet.rb', line 140 def inspect declared = columns shape = declared.nil? ? "headers from the sheet" : "#{declared.size} columns" "#<#{self.class} #{sheet_name}, #{shape}#{" null=#{nulls.first.inspect}" if nulls.any?}>" end |
#read(payload) ⇒ Reader
A pass over one payload. Takes the bytes as a String, which is what Sources::Base hands #parse.
102 |
# File 'lib/active_sanction/parsers/spreadsheet.rb', line 102 def read(payload) = Reader.new(table: self, payload: payload) |
#sheet_name ⇒ String
What to call the sheet being read, for a message: the name or index the caller asked for, or what "the first one" means when they asked for nothing.
122 123 124 125 126 |
# File 'lib/active_sanction/parsers/spreadsheet.rb', line 122 def sheet_name return "the first sheet" if sheet.nil? sheet.is_a?(Integer) ? "sheet #{sheet}" : sheet.to_s.inspect end |
#unescape(text) ⇒ String?
A cell's text with Excel's escapes resolved. Applied to every string a
sheet holds, shared or inline, because a name carrying a literal
_x000D_ is a name nothing will match.
108 109 110 111 112 |
# File 'lib/active_sanction/parsers/spreadsheet.rb', line 108 def unescape(text) return text if text.nil? || !text.include?("_x") text.gsub(ESCAPE) { ::Regexp.last_match(1) || [::Regexp.last_match(2).to_s.hex].pack("U") } end |