hypertabular 0.7.0 → 0.8.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 +4 -4
- data/README.md +48 -16
- data/lib/hypertabular/batch.rb +21 -0
- data/lib/hypertabular/column.rb +12 -0
- data/lib/hypertabular/delimited_reader.rb +91 -23
- data/lib/hypertabular/header.rb +52 -0
- data/lib/hypertabular/native/linux-arm64/libhypertabular.so +0 -0
- data/lib/hypertabular/native/linux-musl-arm64/libhypertabular.so +0 -0
- data/lib/hypertabular/native/linux-musl-x64/libhypertabular.so +0 -0
- data/lib/hypertabular/native/linux-x64/libhypertabular.so +0 -0
- data/lib/hypertabular/native/osx-arm64/libhypertabular.dylib +0 -0
- data/lib/hypertabular/native/osx-x64/libhypertabular.dylib +0 -0
- data/lib/hypertabular/native/win-arm64/hypertabular.dll +0 -0
- data/lib/hypertabular/native/win-x64/hypertabular.dll +0 -0
- data/lib/hypertabular/row.rb +74 -0
- data/lib/hypertabular/runtime/delimited.rb +25 -13
- data/lib/hypertabular/runtime/workbook.rb +47 -12
- data/lib/hypertabular/workbook.rb +98 -24
- data/lib/hypertabular.rb +9 -1
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 77fbc1e69617eb9fcd260821290db1bb81a84dccd309d2590e12292492e3230e
|
|
4
|
+
data.tar.gz: f5c2592bafcb5cbff3b763970ea4b11cf2ec1214ffb275429a06c02a63191346
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ab0f0d11368749e31fb9fa517dc4e317d9e70c05f30f76151018507fb4bd507c554d01c85e2e916759745ec9430c5dd869dcb3af400d9761a634c206b4f7854a
|
|
7
|
+
data.tar.gz: 836188c6db4875e3dba668206514f2b4c3f1cc424781a52833aff8d6b9d4e99582e02d04265569397668170f398401d8fa4caff7b0ab823f1a166f5008d3e73e
|
data/README.md
CHANGED
|
@@ -7,14 +7,14 @@ read a batch at a time into typed columns, with a
|
|
|
7
7
|
```ruby
|
|
8
8
|
require "hypertabular"
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
HyperTabular::
|
|
17
|
-
|
|
10
|
+
HyperTabular::DelimitedReader.open("orders.csv", HyperTabular::Dialect::CSV) do |reader|
|
|
11
|
+
# The header is read first; the plan is built from where its names are.
|
|
12
|
+
header = reader.header # => ["id", "name", "score"]
|
|
13
|
+
reader.bind([
|
|
14
|
+
HyperTabular::Column.i32(header.ordinal("id")),
|
|
15
|
+
HyperTabular::Column.text(header.ordinal("name")),
|
|
16
|
+
HyperTabular::Column.f64(header.ordinal("score"))
|
|
17
|
+
])
|
|
18
18
|
|
|
19
19
|
while (batch = reader.read)
|
|
20
20
|
# A column at a time, decoded in one pass — nil where a cell did not cast…
|
|
@@ -32,17 +32,44 @@ HyperTabular::DelimitedReader.open("orders.csv", HyperTabular::Dialect::CSV, pla
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
#
|
|
35
|
+
# Or a row at a time: a reader, a sheet and a batch are Enumerable over Rows.
|
|
36
|
+
reader = HyperTabular::DelimitedReader.open("orders.csv", HyperTabular::Dialect::CSV)
|
|
37
|
+
reader.bind([HyperTabular::Column.i32(reader.header.ordinal("id"))])
|
|
38
|
+
reader.each { |row| puts "line #{row.line}: #{row.value(0).inspect}" }
|
|
39
|
+
|
|
40
|
+
# A workbook reads into the same batch — from a path, a String or an IO.
|
|
36
41
|
book = HyperTabular::Workbook.open("orders.xlsx")
|
|
37
42
|
book.sheets # => [#<data HyperTabular::SheetInfo name="Orders", hidden=false>]
|
|
38
|
-
sheet = book.sheet("Orders", HyperTabular::SheetOptions::DEFAULT
|
|
43
|
+
sheet = book.sheet("Orders", HyperTabular::SheetOptions::DEFAULT)
|
|
44
|
+
sheet.bind([HyperTabular::Column.decimal(sheet.header.ordinal("Total"))])
|
|
39
45
|
while (batch = sheet.read)
|
|
40
46
|
# …
|
|
41
47
|
end
|
|
42
48
|
```
|
|
43
49
|
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
A plan known up front can be handed over at once —
|
|
51
|
+
`DelimitedReader.open(path, dialect, plan)`, `book.sheet(which, options, plan)` — which binds it
|
|
52
|
+
before the header is read.
|
|
53
|
+
|
|
54
|
+
- **The header** (`reader.header`, `sheet.header`) is a `HyperTabular::Header`: the frozen
|
|
55
|
+
Array of frozen names it always was, which also says where a name is. `header.ordinal(name)`
|
|
56
|
+
is the first column with exactly that name — case and spaces included — and a `KeyError`
|
|
57
|
+
naming it when there is none (or, with a block, the block's value, as `Hash#fetch` does);
|
|
58
|
+
`header.find_ordinal(name)` answers `nil` instead.
|
|
59
|
+
- **Binding** happens once, before the first read: `bind` again raises `RuntimeError`, and so
|
|
60
|
+
does a `read` before `bind` — which `bind` then cures. A plan that is not Columns is an
|
|
61
|
+
`ArgumentError` and leaves the reader unbound. `bound?` says which it is. A `nil` plan
|
|
62
|
+
means "bind later"; `[]` is an empty plan, bound at once.
|
|
63
|
+
`reader.column_count` is the header's count, or the first record's once it has been read.
|
|
64
|
+
- **Rows.** `batch.each`, `reader.each` and `sheet.each` yield a `HyperTabular::Row` —
|
|
65
|
+
`index`, `line`, `get(column)` (also `[]` and `verdict`), `value(column)`, `raw(column)`,
|
|
66
|
+
`to_a` — each answering what the batch's own accessor does for that row. A batch is a copy,
|
|
67
|
+
so a row stays good after the read has moved on. A Row deconstructs as its verdicts, for
|
|
68
|
+
`case row in [HyperCast::Success(value: id), *]`. `reader.each_row` still yields each row as
|
|
69
|
+
a frozen Array of verdicts, and `sheet.each_batch` walks a sheet's batches.
|
|
70
|
+
- **A workbook from an IO.** `Workbook.new(io)` reads anything with `#read` to its end into a
|
|
71
|
+
String the workbook owns, as soon as it is opened; `close_source: true` closes the IO once it
|
|
72
|
+
has been read. The kind of workbook is read from its bytes, never from a name.
|
|
46
73
|
|
|
47
74
|
## The shape
|
|
48
75
|
|
|
@@ -53,11 +80,13 @@ native call per batch, and a column comes out of its buffer in one `String#unpac
|
|
|
53
80
|
boundary is crossed once per few thousand rows, not once per cell.
|
|
54
81
|
|
|
55
82
|
- **One batch class.** `read` returns a `HyperTabular::Batch` — `rows`, `columns`,
|
|
56
|
-
`line(row)`, `values(column)`, `verdicts(column)`, `get(column, row)
|
|
57
|
-
row)` — or `nil` once there are no more rows,
|
|
58
|
-
batch owns what it shows: it stays good after the
|
|
83
|
+
`line(row)`, `values(column)`, `verdicts(column)`, `get(column, row)`, `raw(column,
|
|
84
|
+
row)`, and a `Row` for each row through `each` — or `nil` once there are no more rows,
|
|
85
|
+
for delimited text and a sheet alike. A batch owns what it shows: it stays good after the
|
|
86
|
+
reader has moved on.
|
|
59
87
|
- **Nothing is sniffed.** The `Dialect` states the separator, the quoting and the header;
|
|
60
|
-
`SheetOptions` states a sheet's header
|
|
88
|
+
`SheetOptions` states a sheet's header, whether empty rows are skipped and the batch size;
|
|
89
|
+
the plan
|
|
61
90
|
states each column's door and, for numbers, its `HyperCast::NumFormat`.
|
|
62
91
|
- **HyperCast is the judge.** `HyperCast::Success`, `HyperCast::Fault`,
|
|
63
92
|
`HyperCast::NumFormat`, `HyperCast::Decimal` and the declared options (`:milliseconds`,
|
|
@@ -75,6 +104,9 @@ boundary is crossed once per few thousand rows, not once per cell.
|
|
|
75
104
|
offending text.
|
|
76
105
|
- **A String is read in place.** An IO is read forward only, through a buffer that grows
|
|
77
106
|
when a record does not fit it.
|
|
107
|
+
- **No async API, and none needed.** An IO is read with `#readpartial` or `#read`, which yield
|
|
108
|
+
to the Fiber scheduler (the `async` gem's, or any other) instead of blocking, and a task
|
|
109
|
+
cancelled in one is interrupted there; the core's work between reads is on memory.
|
|
78
110
|
|
|
79
111
|
## The doors
|
|
80
112
|
|
data/lib/hypertabular/batch.rb
CHANGED
|
@@ -17,7 +17,14 @@ module HyperTabular
|
|
|
17
17
|
# HyperCast's gem's own for the same doors: true/false, Integer, Float, HyperCast::Decimal,
|
|
18
18
|
# a hyphenated UUID String, a UTC Time, Date, DateTime, Integer nanoseconds since midnight,
|
|
19
19
|
# Rational seconds, and a UTF-8 String for text.
|
|
20
|
+
#
|
|
21
|
+
# A batch is also Enumerable over its rows, for a caller that thinks in rows: #each yields
|
|
22
|
+
# a Row per row, in order.
|
|
23
|
+
#
|
|
24
|
+
# batch.each { |row| orders << Order.new(row.value(0), row.value(1), row.line) }
|
|
20
25
|
class Batch
|
|
26
|
+
include Enumerable
|
|
27
|
+
|
|
21
28
|
# The verdict of every cell that had no bytes at all: one shared, frozen Fault.
|
|
22
29
|
EMPTY = HyperCast::Fault.new(reason: :empty, offset: 0, length: 0)
|
|
23
30
|
|
|
@@ -110,6 +117,20 @@ module HyperTabular
|
|
|
110
117
|
Runtime.unescape(quoted).force_encoding(Encoding::UTF_8)
|
|
111
118
|
end
|
|
112
119
|
|
|
120
|
+
# Yields a Row for each row of the batch, in order, and returns the batch; without a
|
|
121
|
+
# block, an Enumerator.
|
|
122
|
+
def each
|
|
123
|
+
return to_enum(:each) { @rows } unless block_given?
|
|
124
|
+
|
|
125
|
+
@rows.times { |index| yield Row.new(self, index) }
|
|
126
|
+
self
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The Row at +row+. IndexError for a row outside the batch.
|
|
130
|
+
def row(row)
|
|
131
|
+
Row.new(self, at(row))
|
|
132
|
+
end
|
|
133
|
+
|
|
113
134
|
# The batch in a line — not its cells.
|
|
114
135
|
def inspect
|
|
115
136
|
"#<#{self.class.name} rows=#{@rows} columns=#{@columns.size}>"
|
data/lib/hypertabular/column.rb
CHANGED
|
@@ -132,6 +132,18 @@ module HyperTabular
|
|
|
132
132
|
# String. A cell with no bytes at all is the one way text fails (an :empty Fault).
|
|
133
133
|
def self.text(ordinal) = new(ordinal: ordinal, door: :text)
|
|
134
134
|
|
|
135
|
+
# +plan+ as a reader holds it: a frozen Array of its own, every entry a Column — what
|
|
136
|
+
# DelimitedReader and Sheet check a plan with. ArgumentError naming the first entry that
|
|
137
|
+
# is not a Column.
|
|
138
|
+
def self.plan(plan)
|
|
139
|
+
plan = Array(plan).dup.freeze
|
|
140
|
+
plan.each_with_index do |column, index|
|
|
141
|
+
raise ArgumentError, "plan column #{index} must be a HyperTabular::Column; got #{column.inspect}" unless
|
|
142
|
+
column.is_a?(Column)
|
|
143
|
+
end
|
|
144
|
+
plan
|
|
145
|
+
end
|
|
146
|
+
|
|
135
147
|
# Bytes one value of this column's door takes in a column buffer.
|
|
136
148
|
def value_bytes
|
|
137
149
|
Column::VALUE_BYTES.fetch(door)
|
|
@@ -27,9 +27,21 @@ module HyperTabular
|
|
|
27
27
|
# not rows of cells at all — a record of the wrong width, a quote never closed — is a
|
|
28
28
|
# TabularError, raised after every intact row before it has been delivered.
|
|
29
29
|
#
|
|
30
|
+
# The plan may also wait for the header. Opened without one, the reader reads the header
|
|
31
|
+
# and stops: its names say where each column is, and #bind declares the plan built from
|
|
32
|
+
# them, once, before the first read:
|
|
33
|
+
#
|
|
34
|
+
# reader = HyperTabular::DelimitedReader.open("orders.csv", HyperTabular::Dialect::CSV)
|
|
35
|
+
# header = reader.header # => ["id", "name", "score"]
|
|
36
|
+
# reader.bind([HyperTabular::Column.i32(header.ordinal("id")),
|
|
37
|
+
# HyperTabular::Column.f64(header.ordinal("score"))])
|
|
38
|
+
# reader.each { |row| total += row.value(1) || 0 } # a Row at a time, batch after batch
|
|
39
|
+
#
|
|
30
40
|
# Each #read returns a Batch, which owns what it shows: it stays good after the next
|
|
31
41
|
# #read. Not thread-safe.
|
|
32
42
|
class DelimitedReader
|
|
43
|
+
include Enumerable
|
|
44
|
+
|
|
33
45
|
# The row ceiling: a single record larger than this is a :row_too_long TabularError.
|
|
34
46
|
MAX_ROW_BYTES = 1 << 30
|
|
35
47
|
|
|
@@ -42,10 +54,11 @@ module HyperTabular
|
|
|
42
54
|
# Encodings whose bytes already are the UTF-8 (or byte-identical) form the core reads.
|
|
43
55
|
BYTE_COMPATIBLE = HyperCast::Interop::BYTE_COMPATIBLE
|
|
44
56
|
|
|
45
|
-
# Opens a file of UTF-8 delimited text
|
|
46
|
-
#
|
|
47
|
-
# returns the reader, whose #close
|
|
48
|
-
|
|
57
|
+
# Opens a file of UTF-8 delimited text, through +plan+ — or, with none, header first,
|
|
58
|
+
# for #bind. With a block, yields the reader, closes it — and the file — when the block
|
|
59
|
+
# ends, and returns the block's value; without one, returns the reader, whose #close
|
|
60
|
+
# closes the file.
|
|
61
|
+
def self.open(path, dialect, plan = nil, batch_rows: DEFAULT_BATCH_ROWS, buffer_bytes: DEFAULT_BUFFER_BYTES)
|
|
49
62
|
file = File.open(path, "rb")
|
|
50
63
|
begin
|
|
51
64
|
reader = new(file, dialect, plan, batch_rows: batch_rows, buffer_bytes: buffer_bytes, close_source: true)
|
|
@@ -67,14 +80,15 @@ module HyperTabular
|
|
|
67
80
|
# of +buffer_bytes+ that grows when a record does not fit it. An IO is read as bytes;
|
|
68
81
|
# one opened in text mode on Windows should be in binmode first.
|
|
69
82
|
#
|
|
70
|
-
# +dialect+ is the declared Dialect and +plan+ the output Columns, in output order
|
|
83
|
+
# +dialect+ is the declared Dialect and +plan+ the output Columns, in output order — or
|
|
84
|
+
# nil, for a reader that reads its header first and is given its plan by #bind.
|
|
71
85
|
# +batch_rows+ is the most rows one #read delivers. +close_source+ says whether #close
|
|
72
86
|
# also closes the IO.
|
|
73
87
|
#
|
|
74
88
|
# A dialect the scanner cannot honour, or a plan that is not Columns, is an
|
|
75
89
|
# ArgumentError. With a header declared the header record is read here, so a header
|
|
76
90
|
# that is structurally broken is a TabularError here.
|
|
77
|
-
def initialize(source, dialect, plan, batch_rows: DEFAULT_BATCH_ROWS, buffer_bytes: DEFAULT_BUFFER_BYTES,
|
|
91
|
+
def initialize(source, dialect, plan = nil, batch_rows: DEFAULT_BATCH_ROWS, buffer_bytes: DEFAULT_BUFFER_BYTES,
|
|
78
92
|
close_source: false)
|
|
79
93
|
raise ArgumentError, "dialect must be a HyperTabular::Dialect; got #{dialect.inspect}" unless
|
|
80
94
|
dialect.is_a?(Dialect)
|
|
@@ -83,22 +97,18 @@ module HyperTabular
|
|
|
83
97
|
raise ArgumentError, "buffer_bytes must be a positive Integer; got #{buffer_bytes.inspect}" unless
|
|
84
98
|
buffer_bytes.is_a?(Integer) && buffer_bytes.positive?
|
|
85
99
|
|
|
86
|
-
|
|
87
|
-
@plan.each_with_index do |column, index|
|
|
88
|
-
raise ArgumentError, "plan column #{index} must be a HyperTabular::Column; got #{column.inspect}" unless
|
|
89
|
-
column.is_a?(Column)
|
|
90
|
-
end
|
|
100
|
+
planned = Column.plan(plan) unless plan.nil?
|
|
91
101
|
@dialect = dialect
|
|
92
102
|
@batch_rows = batch_rows
|
|
93
103
|
@buffer_bytes = buffer_bytes
|
|
94
104
|
@close_source = close_source
|
|
95
|
-
|
|
96
|
-
per_row = (@plan.map(&:ordinal).max || -1) + 2
|
|
97
|
-
@kernel = Runtime::Delimited.start(dialect.packed, @plan.map(&:packed), @plan.map(&:value_bytes),
|
|
98
|
-
batch_rows, per_row)
|
|
105
|
+
@kernel = Runtime::Delimited.start(dialect.packed)
|
|
99
106
|
raise ArgumentError, "separator #{dialect.separator.inspect} is not tab or printable ASCII other than '\"'" if
|
|
100
107
|
@kernel.nil?
|
|
101
108
|
|
|
109
|
+
@plan = UNBOUND
|
|
110
|
+
# A plan handed over here is bound before the header is read, as #bind would bind it.
|
|
111
|
+
bind_kernel(planned) if planned
|
|
102
112
|
@start = 0
|
|
103
113
|
@closed = false
|
|
104
114
|
@failure = nil
|
|
@@ -106,14 +116,42 @@ module HyperTabular
|
|
|
106
116
|
@header = dialect.has_header ? read_header : nil
|
|
107
117
|
end
|
|
108
118
|
|
|
109
|
-
# The header's names, when the dialect declares a header: frozen
|
|
110
|
-
#
|
|
111
|
-
#
|
|
119
|
+
# The header's names, when the dialect declares a header: a Header — the frozen Array of
|
|
120
|
+
# frozen UTF-8 Strings, quotes resolved, that also says where a name is
|
|
121
|
+
# (Header#ordinal). Empty for an input with no record at all; nil when the dialect
|
|
122
|
+
# declares no header.
|
|
112
123
|
attr_reader :header
|
|
113
124
|
|
|
114
125
|
# The plan: the output Columns, in output order. Column +i+ of every batch is +plan[i]+.
|
|
126
|
+
# Empty until one is bound.
|
|
115
127
|
attr_reader :plan
|
|
116
128
|
|
|
129
|
+
# Declares the plan a reader opened without one reads through — +plan+, the output
|
|
130
|
+
# Columns, in output order, typically built from the #header's ordinals — once, before
|
|
131
|
+
# the first read. Returns the reader.
|
|
132
|
+
#
|
|
133
|
+
# ArgumentError for a plan that is not Columns, which leaves the reader unbound;
|
|
134
|
+
# RuntimeError if the reader already has a plan; IOError on a closed reader.
|
|
135
|
+
def bind(plan)
|
|
136
|
+
raise IOError, "closed reader" if @closed
|
|
137
|
+
raise "the reader already has a plan: a plan is bound once" if bound?
|
|
138
|
+
|
|
139
|
+
bind_kernel(Column.plan(plan))
|
|
140
|
+
self
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Whether a plan has been bound: always, for a reader opened with one.
|
|
144
|
+
def bound?
|
|
145
|
+
!@plan.equal?(UNBOUND)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# How many cells a record has: the header's count once it has been read, otherwise the
|
|
149
|
+
# first record's once it has been; nil before either.
|
|
150
|
+
def column_count
|
|
151
|
+
expected = @kernel.expected
|
|
152
|
+
expected.zero? ? nil : expected
|
|
153
|
+
end
|
|
154
|
+
|
|
117
155
|
# The declared Dialect.
|
|
118
156
|
attr_reader :dialect
|
|
119
157
|
|
|
@@ -128,10 +166,12 @@ module HyperTabular
|
|
|
128
166
|
# Reads the next batch: a Batch of up to #batch_rows rows, or nil once the input is
|
|
129
167
|
# exhausted. A TabularError when the input is structurally broken — raised after every
|
|
130
168
|
# intact row before the break has been delivered, and the same error again on every
|
|
131
|
-
# later call. IOError on a closed reader
|
|
169
|
+
# later call. IOError on a closed reader; RuntimeError before a plan is bound, which
|
|
170
|
+
# #bind then cures.
|
|
132
171
|
def read
|
|
133
172
|
raise IOError, "closed reader" if @closed
|
|
134
173
|
raise @failure if @failure
|
|
174
|
+
raise UNBOUND_MESSAGE unless bound?
|
|
135
175
|
|
|
136
176
|
loop do
|
|
137
177
|
length, last = window
|
|
@@ -162,6 +202,18 @@ module HyperTabular
|
|
|
162
202
|
self
|
|
163
203
|
end
|
|
164
204
|
|
|
205
|
+
# Every remaining row, batch after batch, as a Row — which, its batch being a copy,
|
|
206
|
+
# stays good after the read has moved on. Returns the reader; without a block, an
|
|
207
|
+
# Enumerator, and the reader is Enumerable through it. Raises what #read raises.
|
|
208
|
+
def each(&block)
|
|
209
|
+
return to_enum(:each) unless block
|
|
210
|
+
|
|
211
|
+
while (batch = read)
|
|
212
|
+
batch.each(&block)
|
|
213
|
+
end
|
|
214
|
+
self
|
|
215
|
+
end
|
|
216
|
+
|
|
165
217
|
# Closes the IO if this reader was told to (+close_source+, or DelimitedReader.open).
|
|
166
218
|
# Batches already read stay good. Safe to call twice.
|
|
167
219
|
def close
|
|
@@ -179,11 +231,26 @@ module HyperTabular
|
|
|
179
231
|
|
|
180
232
|
# The reader in a line — not its buffers.
|
|
181
233
|
def inspect
|
|
182
|
-
"#<#{self.class.name} columns=#{@plan.size} records=#{records}#{'
|
|
234
|
+
"#<#{self.class.name} columns=#{@plan.size} records=#{records}#{' unbound' unless bound?}" \
|
|
235
|
+
"#{' closed' if @closed}>"
|
|
183
236
|
end
|
|
184
237
|
|
|
185
238
|
private
|
|
186
239
|
|
|
240
|
+
# The plan of a reader that has none yet.
|
|
241
|
+
UNBOUND = [].freeze
|
|
242
|
+
# What a read before a plan is bound says.
|
|
243
|
+
UNBOUND_MESSAGE = "the reader has no plan yet: bind one before reading".freeze
|
|
244
|
+
private_constant :UNBOUND, :UNBOUND_MESSAGE
|
|
245
|
+
|
|
246
|
+
# Sizes the kernel's arrays for +plan+ (already checked to be Columns) and takes it.
|
|
247
|
+
def bind_kernel(plan)
|
|
248
|
+
# Cell-table entries one row takes: the widest ordinal the plan reads, plus two.
|
|
249
|
+
per_row = (plan.map(&:ordinal).max || -1) + 2
|
|
250
|
+
@kernel.bind(plan.map(&:packed), plan.map(&:value_bytes), @batch_rows, per_row)
|
|
251
|
+
@plan = plan
|
|
252
|
+
end
|
|
253
|
+
|
|
187
254
|
# Takes the source: a String becomes the whole input, an IO the thing to refill from.
|
|
188
255
|
def take(source)
|
|
189
256
|
if source.is_a?(String)
|
|
@@ -271,7 +338,7 @@ module HyperTabular
|
|
|
271
338
|
end
|
|
272
339
|
@start += consumed
|
|
273
340
|
# An empty input has no header and no rows; the width is unknown.
|
|
274
|
-
return
|
|
341
|
+
return Header.new.freeze if last && (consumed == length || consumed.zero?)
|
|
275
342
|
|
|
276
343
|
refill if consumed.zero?
|
|
277
344
|
end
|
|
@@ -282,7 +349,7 @@ module HyperTabular
|
|
|
282
349
|
def header_names
|
|
283
350
|
spans = @kernel.names
|
|
284
351
|
arena = nil
|
|
285
|
-
|
|
352
|
+
names = Header.new(@kernel.rows) do |index|
|
|
286
353
|
offset = spans[index * 2]
|
|
287
354
|
length = spans[index * 2 + 1]
|
|
288
355
|
name =
|
|
@@ -293,7 +360,8 @@ module HyperTabular
|
|
|
293
360
|
arena.byteslice(offset, length & Runtime::Delimited::SPAN_LENGTH)
|
|
294
361
|
end
|
|
295
362
|
name.freeze
|
|
296
|
-
end
|
|
363
|
+
end
|
|
364
|
+
names.freeze
|
|
297
365
|
end
|
|
298
366
|
|
|
299
367
|
# The structural failure the core just reported, kept: it is final.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module HyperTabular
|
|
2
|
+
# A header's names, in column order: what DelimitedReader#header and Sheet#header return.
|
|
3
|
+
# It is the frozen Array of frozen UTF-8 Strings those have always returned — it equals
|
|
4
|
+
# the Array of the same names, indexes, iterates and pattern-matches as one — and it can
|
|
5
|
+
# also say where a name is, which is what a plan built from names reads with:
|
|
6
|
+
#
|
|
7
|
+
# reader = HyperTabular::DelimitedReader.open("orders.csv", HyperTabular::Dialect::CSV)
|
|
8
|
+
# header = reader.header # => ["id", "name", "score"]
|
|
9
|
+
# reader.bind([HyperTabular::Column.text(header.ordinal("name")),
|
|
10
|
+
# HyperTabular::Column.f64(header.ordinal("score"))])
|
|
11
|
+
#
|
|
12
|
+
# A name matches exactly — byte for byte, case and spaces included — and a name the header
|
|
13
|
+
# has twice is found where it is first.
|
|
14
|
+
class Header < Array
|
|
15
|
+
# The ordinal of the first column named +name+ (a String). A name the header does not
|
|
16
|
+
# have is a KeyError that says which — or, with a block, the block's value, the block
|
|
17
|
+
# handed the name, as Hash#fetch does:
|
|
18
|
+
#
|
|
19
|
+
# header.ordinal("id") # => 0
|
|
20
|
+
# header.ordinal("missing") # KeyError: the header has no column named "missing"
|
|
21
|
+
# header.ordinal("missing") { nil } # => nil
|
|
22
|
+
#
|
|
23
|
+
# A binary String is matched as the bytes it holds; a String in another encoding is
|
|
24
|
+
# matched as its UTF-8 transcoding. TypeError for anything that is not a String.
|
|
25
|
+
def ordinal(name)
|
|
26
|
+
found = find_ordinal(name)
|
|
27
|
+
return found unless found.nil?
|
|
28
|
+
return yield(name) if block_given?
|
|
29
|
+
|
|
30
|
+
raise KeyError.new("the header has no column named #{name.inspect}", receiver: self, key: name)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The ordinal of the first column named +name+, or nil when the header has none: #ordinal
|
|
34
|
+
# without the KeyError, matched the same way.
|
|
35
|
+
def find_ordinal(name)
|
|
36
|
+
raise TypeError, "a column name is a String; got #{name.class}" unless name.is_a?(String)
|
|
37
|
+
|
|
38
|
+
wanted =
|
|
39
|
+
case name.encoding
|
|
40
|
+
when Encoding::UTF_8 then name
|
|
41
|
+
when Encoding::BINARY then name.dup.force_encoding(Encoding::UTF_8)
|
|
42
|
+
else
|
|
43
|
+
begin
|
|
44
|
+
name.encode(Encoding::UTF_8)
|
|
45
|
+
rescue EncodingError
|
|
46
|
+
return nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
index(wanted)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
module HyperTabular
|
|
2
|
+
# One row of a Batch: what Batch#each, DelimitedReader#each and Sheet#each yield, for a
|
|
3
|
+
# caller that thinks in rows — "for each record, build an object from these columns" —
|
|
4
|
+
# rather than in columns. A view of (batch, index): every member answers what the batch's
|
|
5
|
+
# own accessor answers for that row, nothing is copied, and since a batch owns what it
|
|
6
|
+
# shows, a row stays good after the reader has moved on.
|
|
7
|
+
#
|
|
8
|
+
# reader.each do |row|
|
|
9
|
+
# case row.get(0)
|
|
10
|
+
# in HyperCast::Success(value: id) then orders << Order.new(id, row.value(1), row.line)
|
|
11
|
+
# in HyperCast::Fault(reason:) then warn "line #{row.line}: #{reason} in #{row.raw(0).inspect}"
|
|
12
|
+
# end
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# It deconstructs as its verdicts, one per plan column, so a whole row can be matched at
|
|
16
|
+
# once: `in [HyperCast::Success(value: id), HyperCast::Success(value: name)]`.
|
|
17
|
+
class Row
|
|
18
|
+
# The batch the row is in.
|
|
19
|
+
attr_reader :batch
|
|
20
|
+
|
|
21
|
+
# The row's 0-based index within its batch.
|
|
22
|
+
attr_reader :index
|
|
23
|
+
|
|
24
|
+
# Made by Batch#each.
|
|
25
|
+
def initialize(batch, index)
|
|
26
|
+
@batch = batch
|
|
27
|
+
@index = index
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Where the row came from — Batch#line: for delimited text the 1-based line its record
|
|
32
|
+
# starts on, for a sheet its 1-based row number.
|
|
33
|
+
def line
|
|
34
|
+
@batch.line(@index)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The cell in plan column +column+ as HyperCast judged it — Batch#get. IndexError for a
|
|
38
|
+
# column outside the plan.
|
|
39
|
+
def get(column)
|
|
40
|
+
@batch.get(column, @index)
|
|
41
|
+
end
|
|
42
|
+
alias [] get
|
|
43
|
+
alias verdict get
|
|
44
|
+
|
|
45
|
+
# The cell's value, or nil where it did not cast — Batch#values' entry for this row.
|
|
46
|
+
# IndexError for a column outside the plan.
|
|
47
|
+
def value(column)
|
|
48
|
+
@batch.values(column)[@index]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The text the cell was cast from, whatever its door and verdict — Batch#raw.
|
|
52
|
+
def raw(column)
|
|
53
|
+
@batch.raw(column, @index)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# How many cells the row has: one per plan column.
|
|
57
|
+
def size
|
|
58
|
+
@batch.columns.size
|
|
59
|
+
end
|
|
60
|
+
alias length size
|
|
61
|
+
|
|
62
|
+
# Every cell's verdict, in plan order, as a frozen Array — the Array
|
|
63
|
+
# DelimitedReader#each_row yields for the same row.
|
|
64
|
+
def to_a
|
|
65
|
+
Array.new(size) { |column| get(column) }.freeze
|
|
66
|
+
end
|
|
67
|
+
alias deconstruct to_a
|
|
68
|
+
|
|
69
|
+
# The row in a line — not its cells.
|
|
70
|
+
def inspect
|
|
71
|
+
"#<#{self.class.name} index=#{@index} line=#{line}>"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -7,7 +7,9 @@ module HyperTabular
|
|
|
7
7
|
#
|
|
8
8
|
# The memory is allocated once and reused for every batch: the state block, the plan,
|
|
9
9
|
# one value array and one verdict array per column, the cell table, the arena. The core
|
|
10
|
-
# keeps none of it between calls beyond what it writes into the state block.
|
|
10
|
+
# keeps none of it between calls beyond what it writes into the state block. The plan's
|
|
11
|
+
# arrays and the cell table wait for #bind, so the header can be read before there is a
|
|
12
|
+
# plan.
|
|
11
13
|
class Delimited
|
|
12
14
|
# The call did what it could; the result says how far it got.
|
|
13
15
|
OK = 0
|
|
@@ -51,7 +53,8 @@ module HyperTabular
|
|
|
51
53
|
attr_reader :rows, :consumed, :arena_used, :failure
|
|
52
54
|
|
|
53
55
|
# The plan's arrays (Columns), and the cell-table entries one row takes: the widest
|
|
54
|
-
# ordinal the plan reads, plus two — or more, if the core asked for more.
|
|
56
|
+
# ordinal the plan reads, plus two — or more, if the core asked for more. Nil until
|
|
57
|
+
# #bind.
|
|
55
58
|
attr_reader :columns, :per_row
|
|
56
59
|
|
|
57
60
|
# The loaded core's version word, major << 16 | minor << 8 | patch.
|
|
@@ -59,24 +62,17 @@ module HyperTabular
|
|
|
59
62
|
Runtime.function(:hypertabular_version).call
|
|
60
63
|
end
|
|
61
64
|
|
|
62
|
-
# +dialect+ is the four bytes of a RawDialect
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def self.start(dialect, specs, sizes, batch_rows, per_row)
|
|
66
|
-
reader = new(specs, sizes, batch_rows, per_row)
|
|
65
|
+
# +dialect+ is the four bytes of a RawDialect. Nil when the core refuses it.
|
|
66
|
+
def self.start(dialect)
|
|
67
|
+
reader = new
|
|
67
68
|
reader.send(:init, dialect) ? reader : nil
|
|
68
69
|
end
|
|
69
70
|
|
|
70
|
-
def initialize
|
|
71
|
+
def initialize
|
|
71
72
|
@header = Runtime.function(:hypertabular_delimited_header)
|
|
72
73
|
@fill = Runtime.function(:hypertabular_delimited_fill)
|
|
73
74
|
@state = Runtime.buffer(Runtime.function(:hypertabular_delimited_state_size).call)
|
|
74
|
-
@batch_rows = batch_rows
|
|
75
|
-
@columns = Columns.new(specs, sizes, batch_rows)
|
|
76
|
-
@per_row = per_row
|
|
77
75
|
@cramped = false
|
|
78
|
-
@cells_cap = per_row * batch_rows
|
|
79
|
-
@cells = Runtime.buffer(SPAN_BYTES * @cells_cap)
|
|
80
76
|
@arena_cap = ARENA_BYTES
|
|
81
77
|
@arena = Runtime.buffer(@arena_cap)
|
|
82
78
|
@out = Runtime.buffer(FILLED_BYTES)
|
|
@@ -84,6 +80,17 @@ module HyperTabular
|
|
|
84
80
|
@rows = @consumed = @arena_used = 0
|
|
85
81
|
end
|
|
86
82
|
|
|
83
|
+
# Takes the plan: +specs+ is one packed ColumnSpec per plan column and +sizes+ the
|
|
84
|
+
# bytes one value of each takes, +batch_rows+ the most rows a fill writes and +per_row+
|
|
85
|
+
# the cell-table entries one row takes. Once, before the first #fill.
|
|
86
|
+
def bind(specs, sizes, batch_rows, per_row)
|
|
87
|
+
@batch_rows = batch_rows
|
|
88
|
+
@columns = Columns.new(specs, sizes, batch_rows)
|
|
89
|
+
@per_row = per_row
|
|
90
|
+
@cells_cap = per_row * batch_rows
|
|
91
|
+
@cells = Runtime.buffer(SPAN_BYTES * @cells_cap)
|
|
92
|
+
end
|
|
93
|
+
|
|
87
94
|
# Names the String the calls that follow read: +start+ and +offset+ below index its
|
|
88
95
|
# bytes. It is held where it is, not copied, and must not be modified until another
|
|
89
96
|
# one is attached.
|
|
@@ -173,6 +180,11 @@ module HyperTabular
|
|
|
173
180
|
@state[16, 8].unpack1("Q<")
|
|
174
181
|
end
|
|
175
182
|
|
|
183
|
+
# Cells per record, fixed by the header or the first record; 0 before either.
|
|
184
|
+
def expected
|
|
185
|
+
@state[24, 4].unpack1("L<")
|
|
186
|
+
end
|
|
187
|
+
|
|
176
188
|
private
|
|
177
189
|
|
|
178
190
|
def init(dialect)
|
|
@@ -51,7 +51,11 @@ module HyperTabular
|
|
|
51
51
|
# it opens, one for each sheet.
|
|
52
52
|
class Scratch
|
|
53
53
|
# The buffers, each a Fiddle::Pointer, and their sizes (in bytes, spans, slots).
|
|
54
|
-
attr_reader :window, :arena, :cells, :arena_cap, :cells_cap
|
|
54
|
+
attr_reader :window, :arena, :cells, :arena_cap, :cells_cap, :row_cap
|
|
55
|
+
|
|
56
|
+
# Whether the row's slots grow with the cell table: what a header read before there
|
|
57
|
+
# is a plan needs, so that there is a slot for every cell the header names.
|
|
58
|
+
attr_accessor :row_follows_cells
|
|
55
59
|
|
|
56
60
|
def initialize(window, arena, cells, row)
|
|
57
61
|
@window_cap = window
|
|
@@ -84,9 +88,22 @@ module HyperTabular
|
|
|
84
88
|
@arena, @arena_cap = grown(@arena, @arena_cap, needed, 1)
|
|
85
89
|
end
|
|
86
90
|
|
|
87
|
-
# Makes the cell table at least +needed+ spans, what it held kept
|
|
91
|
+
# Makes the cell table at least +needed+ spans, what it held kept — and, while the
|
|
92
|
+
# row follows it, the row's slots as many.
|
|
88
93
|
def grow_cells(needed)
|
|
89
94
|
@cells, @cells_cap = grown(@cells, @cells_cap, needed, SPAN_BYTES)
|
|
95
|
+
reserve_row(@cells_cap) if @row_follows_cells
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Makes the cell table at least +needed+ spans if it is smaller, what it held kept.
|
|
99
|
+
def reserve_cells(needed)
|
|
100
|
+
@cells, @cells_cap = grown(@cells, @cells_cap, needed, SPAN_BYTES) if @cells_cap < needed
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Makes the row at least +needed+ slots if it is smaller, what it held kept: a header
|
|
104
|
+
# row still to be repeated is never dropped.
|
|
105
|
+
def reserve_row(needed)
|
|
106
|
+
@row, @row_cap = grown(@row, @row_cap, needed, SLOT_BYTES) if @row_cap < needed
|
|
90
107
|
end
|
|
91
108
|
|
|
92
109
|
# Makes +call+ (a block taking the Buffers block and the out block) until it stops
|
|
@@ -210,18 +227,19 @@ module HyperTabular
|
|
|
210
227
|
end
|
|
211
228
|
end
|
|
212
229
|
|
|
213
|
-
# One sheet being read: its own copy of the state, its plan's arrays, its scratch.
|
|
230
|
+
# One sheet being read: its own copy of the state, its plan's arrays, its scratch. The
|
|
231
|
+
# plan's arrays wait for #bind, so that the header can be read before there is a plan.
|
|
214
232
|
class Reading
|
|
215
233
|
# The plan's arrays, and the cell-table entries one row takes: one per plan column,
|
|
216
|
-
# and one for the row's number.
|
|
217
|
-
attr_reader :columns, :per_row
|
|
234
|
+
# and one for the row's number. Nil until #bind.
|
|
235
|
+
attr_reader :columns, :per_row
|
|
218
236
|
|
|
219
|
-
|
|
237
|
+
# Positions a copy of the workbook's state on +sheet+ (an entry of Opened#sheets).
|
|
238
|
+
def initialize(book, sheet, has_header, skip_empty_rows, batch_rows)
|
|
220
239
|
@book = book
|
|
221
240
|
@state = book.copy_of_state
|
|
222
|
-
@
|
|
223
|
-
@
|
|
224
|
-
@scratch = Book.stingy ? Scratch.new(1, 1, 1, width) : Scratch.new(0, 4096, batch_rows * @per_row, width)
|
|
241
|
+
@batch_rows = batch_rows
|
|
242
|
+
@scratch = Book.stingy ? Scratch.new(1, 1, 1, 0) : Scratch.new(0, 4096, 64, 0)
|
|
225
243
|
@out = Runtime.buffer(FILLED_BYTES)
|
|
226
244
|
_name, _hidden, part, index = sheet
|
|
227
245
|
code = Runtime.function(:hypertabular_workbook_sheet).call(
|
|
@@ -229,7 +247,19 @@ module HyperTabular
|
|
|
229
247
|
has_header ? 1 : 0, skip_empty_rows ? 1 : 0, @out
|
|
230
248
|
)
|
|
231
249
|
Book.settle(code, @out[0, FILLED_BYTES].unpack(FILLED))
|
|
232
|
-
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Takes the plan: +specs+ is one packed ColumnSpec per plan column, +sizes+ the bytes
|
|
253
|
+
# one value of each takes, and +width+ the slots a row needs to reach every source
|
|
254
|
+
# column the plan reads. Once, before the first #fill. The core keeps a header row's
|
|
255
|
+
# cells in the row's slots — a row the sheet repeats (an ODS number-rows-repeated) is
|
|
256
|
+
# delivered again from them — so the slots are only ever grown here, never replaced.
|
|
257
|
+
def bind(specs, sizes, width)
|
|
258
|
+
@columns = Columns.new(specs, sizes, @batch_rows)
|
|
259
|
+
@per_row = specs.size + 1
|
|
260
|
+
# A stingy scratch is left to grow into the cell table the hard way, mid-read.
|
|
261
|
+
@scratch.reserve_cells(@batch_rows * @per_row) unless Book.stingy
|
|
262
|
+
@scratch.reserve_row(width)
|
|
233
263
|
end
|
|
234
264
|
|
|
235
265
|
# The next batch's rows, its cell table, the arena as far as the core wrote into it
|
|
@@ -249,13 +279,18 @@ module HyperTabular
|
|
|
249
279
|
code == OK ? nil : filled[4..]]
|
|
250
280
|
end
|
|
251
281
|
|
|
252
|
-
|
|
253
|
-
|
|
282
|
+
# Reads the header row: its names, as frozen UTF-8 Strings in a frozen Array. Read
|
|
283
|
+
# before #bind, the row's slots are made — and kept — as many as the cell table has
|
|
284
|
+
# spans, which is a slot for every name.
|
|
254
285
|
def read_header
|
|
255
286
|
call = Runtime.function(:hypertabular_workbook_header)
|
|
287
|
+
unbound = @columns.nil?
|
|
288
|
+
@scratch.reserve_row(@scratch.cells_cap) if unbound
|
|
289
|
+
@scratch.row_follows_cells = unbound
|
|
256
290
|
code, filled = @scratch.drive(@out, @book.tables) do |buffers|
|
|
257
291
|
call.call(@state, @book.container, @book.length, buffers, @out)
|
|
258
292
|
end
|
|
293
|
+
@scratch.row_follows_cells = false
|
|
259
294
|
rows = Book.settle(code, filled).first
|
|
260
295
|
spans = @scratch.cells[0, SPAN_BYTES * rows].unpack("L<*")
|
|
261
296
|
arena = nil
|
|
@@ -24,7 +24,8 @@ module HyperTabular
|
|
|
24
24
|
#
|
|
25
25
|
# book = HyperTabular::Workbook.open("orders.xlsx")
|
|
26
26
|
# book.sheets # => [#<data HyperTabular::SheetInfo name="Orders", hidden=false>]
|
|
27
|
-
# sheet = book.sheet("Orders", HyperTabular::SheetOptions::DEFAULT
|
|
27
|
+
# sheet = book.sheet("Orders", HyperTabular::SheetOptions::DEFAULT)
|
|
28
|
+
# sheet.bind([HyperTabular::Column.i32(sheet.header.ordinal("id"))])
|
|
28
29
|
# while (batch = sheet.read) ... end
|
|
29
30
|
#
|
|
30
31
|
# A workbook that cannot be read — not a zip, encrypted, a part missing or broken — is a
|
|
@@ -36,12 +37,23 @@ module HyperTabular
|
|
|
36
37
|
new(File.binread(path))
|
|
37
38
|
end
|
|
38
39
|
|
|
39
|
-
# Opens the workbook in +
|
|
40
|
-
# the workbook's as it is, anything else is copied once
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
# Opens the workbook in +source+: a String, which is read in place — a frozen String is
|
|
41
|
+
# the workbook's as it is, anything else is copied once — or an IO (anything with
|
|
42
|
+
# #read), read to its end into a String the workbook owns, as soon as it is opened; the
|
|
43
|
+
# IO is closed once it has been read if +close_source+ says so. Which kind of workbook
|
|
44
|
+
# it is is read from its bytes, never from a name.
|
|
45
|
+
def initialize(source, close_source: false)
|
|
46
|
+
bytes =
|
|
47
|
+
if source.is_a?(String)
|
|
48
|
+
source.frozen? ? source : source.b.freeze
|
|
49
|
+
elsif defined?(::Pathname) && source.is_a?(::Pathname)
|
|
50
|
+
# It has a #read, but it is a path, and Workbook.open is what reads one.
|
|
51
|
+
raise ArgumentError, "source is a Pathname; Workbook.open reads a path"
|
|
52
|
+
elsif source.respond_to?(:read)
|
|
53
|
+
drain(source, close_source)
|
|
54
|
+
else
|
|
55
|
+
raise ArgumentError, "source must be a String or an IO; got #{source.class}"
|
|
56
|
+
end
|
|
45
57
|
@book = Runtime::Book::Opened.new(bytes)
|
|
46
58
|
@sheets = @book.sheets.map { |name, hidden, _part, _index| SheetInfo.new(name: name, hidden: hidden) }.freeze
|
|
47
59
|
rescue Runtime::Book::StructureError => e
|
|
@@ -64,10 +76,11 @@ module HyperTabular
|
|
|
64
76
|
end
|
|
65
77
|
|
|
66
78
|
# Starts a read of one sheet — +which+ is its index in #sheets or its name — through
|
|
67
|
-
# +plan+, as +options+ (SheetOptions) says
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
|
|
79
|
+
# +plan+, as +options+ (SheetOptions) says; or, with no plan, header first, the plan to
|
|
80
|
+
# be given by Sheet#bind once the header has said where each column is. With a header
|
|
81
|
+
# declared the header row is read here. IndexError for an index the workbook has no
|
|
82
|
+
# sheet at, KeyError for a name it has no sheet by.
|
|
83
|
+
def sheet(which, options, plan = nil)
|
|
71
84
|
index =
|
|
72
85
|
case which
|
|
73
86
|
when Integer
|
|
@@ -89,6 +102,18 @@ module HyperTabular
|
|
|
89
102
|
def inspect
|
|
90
103
|
"#<#{self.class.name} #{format} sheets=#{@sheets.size}>"
|
|
91
104
|
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# Everything +io+ has left, as a frozen binary String of the workbook's own; the IO
|
|
109
|
+
# closed after, if +close+ says so, whether or not the read went well.
|
|
110
|
+
def drain(io, close)
|
|
111
|
+
bytes = io.read
|
|
112
|
+
bytes = bytes.nil? ? String.new : bytes.dup
|
|
113
|
+
bytes.force_encoding(Encoding::BINARY).freeze
|
|
114
|
+
ensure
|
|
115
|
+
io.close if close
|
|
116
|
+
end
|
|
92
117
|
end
|
|
93
118
|
|
|
94
119
|
# A forward-only read of one sheet of a Workbook, a batch at a time, through a plan — into
|
|
@@ -99,14 +124,17 @@ module HyperTabular
|
|
|
99
124
|
# sheet that is structurally broken raises a TabularError after every intact row before
|
|
100
125
|
# the break has been delivered, and the same error again on every later read.
|
|
101
126
|
class Sheet
|
|
127
|
+
include Enumerable
|
|
128
|
+
|
|
102
129
|
# The SheetOptions the sheet is read with.
|
|
103
130
|
attr_reader :options
|
|
104
131
|
|
|
105
|
-
# The plan: the output Columns, in output order.
|
|
132
|
+
# The plan: the output Columns, in output order. Empty until one is bound.
|
|
106
133
|
attr_reader :plan
|
|
107
134
|
|
|
108
|
-
# The header row's names — a typed cell said the way the text door says it — as
|
|
109
|
-
# UTF-8 Strings,
|
|
135
|
+
# The header row's names — a typed cell said the way the text door says it — as a
|
|
136
|
+
# Header (the frozen Array of frozen UTF-8 Strings, which also says where a name is), or
|
|
137
|
+
# nil when the options declare no header.
|
|
110
138
|
attr_reader :header
|
|
111
139
|
|
|
112
140
|
# Made by Workbook#sheet.
|
|
@@ -114,26 +142,46 @@ module HyperTabular
|
|
|
114
142
|
raise ArgumentError, "options must be a HyperTabular::SheetOptions; got #{options.inspect}" unless
|
|
115
143
|
options.is_a?(SheetOptions)
|
|
116
144
|
|
|
117
|
-
|
|
118
|
-
@plan.each_with_index do |column, index|
|
|
119
|
-
raise ArgumentError, "plan column #{index} must be a HyperTabular::Column; got #{column.inspect}" unless
|
|
120
|
-
column.is_a?(Column)
|
|
121
|
-
end
|
|
145
|
+
planned = Column.plan(plan) unless plan.nil?
|
|
122
146
|
@book = book
|
|
123
147
|
@options = options
|
|
124
|
-
|
|
148
|
+
@plan = UNBOUND
|
|
125
149
|
@reading = Runtime::Book::Reading.new(book, sheet, options.has_header, options.skip_empty_rows,
|
|
126
|
-
|
|
127
|
-
|
|
150
|
+
options.batch_rows)
|
|
151
|
+
# A plan handed over here is bound before the header is read, as #bind would bind it;
|
|
152
|
+
# without one the header row is read into slots enough for every cell it has, and
|
|
153
|
+
# binding later keeps them (a row the sheet repeats is delivered from them again).
|
|
154
|
+
bind_reading(planned) if planned
|
|
155
|
+
@header = options.has_header ? Header.new(@reading.read_header).freeze : nil
|
|
128
156
|
@pending = nil
|
|
129
157
|
@failure = nil
|
|
130
158
|
rescue Runtime::Book::StructureError => e
|
|
131
159
|
raise TabularError.from(e.failure)
|
|
132
160
|
end
|
|
133
161
|
|
|
162
|
+
# Declares the plan a sheet opened without one reads through — +plan+, the output
|
|
163
|
+
# Columns, in output order, typically built from the #header's ordinals — once, before
|
|
164
|
+
# the first read. Returns the sheet.
|
|
165
|
+
#
|
|
166
|
+
# ArgumentError for a plan that is not Columns, which leaves the sheet unbound;
|
|
167
|
+
# RuntimeError if the sheet already has a plan.
|
|
168
|
+
def bind(plan)
|
|
169
|
+
raise "the sheet already has a plan: a plan is bound once" if bound?
|
|
170
|
+
|
|
171
|
+
bind_reading(Column.plan(plan))
|
|
172
|
+
self
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Whether a plan has been bound: always, for a sheet opened with one.
|
|
176
|
+
def bound?
|
|
177
|
+
!@plan.equal?(UNBOUND)
|
|
178
|
+
end
|
|
179
|
+
|
|
134
180
|
# Reads the next batch: a Batch of up to the options' batch_rows rows, or nil once the
|
|
135
|
-
# sheet has no more.
|
|
181
|
+
# sheet has no more. RuntimeError before a plan is bound, which #bind then cures.
|
|
136
182
|
def read
|
|
183
|
+
raise "the sheet has no plan yet: bind one before reading" unless bound?
|
|
184
|
+
|
|
137
185
|
if @pending
|
|
138
186
|
@failure = @pending
|
|
139
187
|
@pending = nil
|
|
@@ -165,9 +213,35 @@ module HyperTabular
|
|
|
165
213
|
self
|
|
166
214
|
end
|
|
167
215
|
|
|
216
|
+
# Every remaining row, batch after batch, as a Row — which, its batch being a copy,
|
|
217
|
+
# stays good after the read has moved on. Returns the sheet; without a block, an
|
|
218
|
+
# Enumerator, and the sheet is Enumerable through it. Raises what #read raises.
|
|
219
|
+
def each(&block)
|
|
220
|
+
return to_enum(:each) unless block
|
|
221
|
+
|
|
222
|
+
while (batch = read)
|
|
223
|
+
batch.each(&block)
|
|
224
|
+
end
|
|
225
|
+
self
|
|
226
|
+
end
|
|
227
|
+
|
|
168
228
|
# The sheet in a line.
|
|
169
229
|
def inspect
|
|
170
|
-
"#<#{self.class.name} columns=#{@plan.size}>"
|
|
230
|
+
"#<#{self.class.name} columns=#{@plan.size}#{' unbound' unless bound?}>"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private
|
|
234
|
+
|
|
235
|
+
# The plan of a sheet that has none yet.
|
|
236
|
+
UNBOUND = [].freeze
|
|
237
|
+
private_constant :UNBOUND
|
|
238
|
+
|
|
239
|
+
# Sizes the reading's arrays for +plan+ (already checked to be Columns) and takes it.
|
|
240
|
+
def bind_reading(plan)
|
|
241
|
+
# Slots the core assembles a row in: one for every source column the plan reaches.
|
|
242
|
+
width = (plan.map(&:ordinal).max || -1) + 1
|
|
243
|
+
@reading.bind(plan.map(&:packed), plan.map(&:value_bytes), width)
|
|
244
|
+
@plan = plan
|
|
171
245
|
end
|
|
172
246
|
end
|
|
173
247
|
end
|
data/lib/hypertabular.rb
CHANGED
|
@@ -22,6 +22,12 @@ require_relative "hypertabular/runtime/workbook"
|
|
|
22
22
|
# sheet = book.sheet("Orders", HyperTabular::SheetOptions::DEFAULT, plan)
|
|
23
23
|
# sheet.read # => the same Batch
|
|
24
24
|
#
|
|
25
|
+
# Or header first, the plan built from the names the header says are there:
|
|
26
|
+
#
|
|
27
|
+
# reader = HyperTabular::DelimitedReader.new("id,name\n1,alice\n", HyperTabular::Dialect::CSV)
|
|
28
|
+
# reader.bind([HyperTabular::Column.text(reader.header.ordinal("name"))])
|
|
29
|
+
# reader.map { |row| row.value(0) } # => ["alice"]
|
|
30
|
+
#
|
|
25
31
|
# Nothing is sniffed: the Dialect states the separator, the quoting and the header,
|
|
26
32
|
# SheetOptions a sheet's header and whether empty rows are skipped, and the plan states each
|
|
27
33
|
# Column's door and, for numbers, its notation. HyperCast is the judge:
|
|
@@ -32,7 +38,7 @@ require_relative "hypertabular/runtime/workbook"
|
|
|
32
38
|
module HyperTabular
|
|
33
39
|
# This gem's own version — kept in lockstep with hypertabular.gemspec and the core's
|
|
34
40
|
# rust/Cargo.toml.
|
|
35
|
-
VERSION = "0.
|
|
41
|
+
VERSION = "0.8.0"
|
|
36
42
|
|
|
37
43
|
class << self
|
|
38
44
|
# Whether the native core loaded and exports the ABI this binding was built against.
|
|
@@ -62,6 +68,8 @@ end
|
|
|
62
68
|
require_relative "hypertabular/dialect"
|
|
63
69
|
require_relative "hypertabular/column"
|
|
64
70
|
require_relative "hypertabular/tabular_error"
|
|
71
|
+
require_relative "hypertabular/header"
|
|
72
|
+
require_relative "hypertabular/row"
|
|
65
73
|
require_relative "hypertabular/batch"
|
|
66
74
|
require_relative "hypertabular/delimited_reader"
|
|
67
75
|
require_relative "hypertabular/workbook"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hypertabular
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Buvinghausen
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.
|
|
18
|
+
version: 0.8.0
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.
|
|
25
|
+
version: 0.8.0
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: fiddle
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -84,6 +84,7 @@ files:
|
|
|
84
84
|
- lib/hypertabular/column.rb
|
|
85
85
|
- lib/hypertabular/delimited_reader.rb
|
|
86
86
|
- lib/hypertabular/dialect.rb
|
|
87
|
+
- lib/hypertabular/header.rb
|
|
87
88
|
- lib/hypertabular/native/linux-arm64/libhypertabular.so
|
|
88
89
|
- lib/hypertabular/native/linux-musl-arm64/libhypertabular.so
|
|
89
90
|
- lib/hypertabular/native/linux-musl-x64/libhypertabular.so
|
|
@@ -92,6 +93,7 @@ files:
|
|
|
92
93
|
- lib/hypertabular/native/osx-x64/libhypertabular.dylib
|
|
93
94
|
- lib/hypertabular/native/win-arm64/hypertabular.dll
|
|
94
95
|
- lib/hypertabular/native/win-x64/hypertabular.dll
|
|
96
|
+
- lib/hypertabular/row.rb
|
|
95
97
|
- lib/hypertabular/runtime.rb
|
|
96
98
|
- lib/hypertabular/runtime/columns.rb
|
|
97
99
|
- lib/hypertabular/runtime/delimited.rb
|