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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad43df079742ea93176f0167a3189f4ec714adfe29ec4379b25a88d8ef500edb
4
- data.tar.gz: 5073a2aaa4d69f2d5198fd02f946e78f5559816f7997be2a600db9664b19edfc
3
+ metadata.gz: 77fbc1e69617eb9fcd260821290db1bb81a84dccd309d2590e12292492e3230e
4
+ data.tar.gz: f5c2592bafcb5cbff3b763970ea4b11cf2ec1214ffb275429a06c02a63191346
5
5
  SHA512:
6
- metadata.gz: f331637842578a2c3fae1398eb9e17572656aed61529f6e2eb954249ec347a3c68d5d3be515a30800dc37f3788f9f6e03dcb57ae2dc2856d028e22e657b67a7d
7
- data.tar.gz: 0f3bf0326dd2111d31bdabc5c0149422b7614f10fa71212a463db1a7f1c1e73fbcea5b0b73d2a69898fa3eec7fbc5750f31c6d06d5cb69373af04eece0ccd38a
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
- plan = [
11
- HyperTabular::Column.i32(0),
12
- HyperTabular::Column.text(1),
13
- HyperTabular::Column.f64(2)
14
- ]
15
-
16
- HyperTabular::DelimitedReader.open("orders.csv", HyperTabular::Dialect::CSV, plan) do |reader|
17
- reader.header # => ["id", "name", "score"]
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
- # A workbook reads into the same batch.
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, plan)
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
- `reader.each_row` walks every remaining row as an Array of verdicts, for when a row at a
45
- time is what the caller wants; `sheet.each_batch` walks a sheet's batches.
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)` and `raw(column,
57
- row)` — or `nil` once there are no more rows, for delimited text and a sheet alike. A
58
- batch owns what it shows: it stays good after the reader has moved on.
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 and whether empty rows are skipped; the plan
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
 
@@ -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}>"
@@ -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. With a block, yields the reader, closes it —
46
- # and the file — when the block ends, and returns the block's value; without one,
47
- # returns the reader, whose #close closes the file.
48
- def self.open(path, dialect, plan, batch_rows: DEFAULT_BATCH_ROWS, buffer_bytes: DEFAULT_BUFFER_BYTES)
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
- @plan = Array(plan).dup.freeze
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
- # Cell-table entries one row takes: the widest ordinal the plan reads, plus two.
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 UTF-8 Strings, quotes
110
- # resolved. Empty for an input with no record at all; nil when the dialect declares no
111
- # header.
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}#{' closed' if @closed}>"
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 [].freeze if last && (consumed == length || consumed.zero?)
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
- Array.new(@kernel.rows) do |index|
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.freeze
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
@@ -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; +specs+ one packed ColumnSpec per plan
63
- # column and +sizes+ the bytes one value of each takes; +per_row+ the cell-table
64
- # entries one row takes. Nil when the core refuses the dialect.
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(specs, sizes, batch_rows, per_row)
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, :header
234
+ # and one for the row's number. Nil until #bind.
235
+ attr_reader :columns, :per_row
218
236
 
219
- def initialize(book, sheet, has_header, skip_empty_rows, specs, sizes, batch_rows, width)
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
- @columns = Columns.new(specs, sizes, batch_rows)
223
- @per_row = specs.size + 1
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
- @header = read_header if has_header
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
- private
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, plan)
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 +bytes+, a String, which is read in place: a frozen String is
40
- # the workbook's as it is, anything else is copied once.
41
- def initialize(bytes)
42
- raise ArgumentError, "bytes must be a String; got #{bytes.class}" unless bytes.is_a?(String)
43
-
44
- bytes = bytes.b.freeze unless bytes.frozen?
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. With a header declared the header row is
68
- # read here. IndexError for an index the workbook has no sheet at, KeyError for a name
69
- # it has no sheet by.
70
- def sheet(which, options, plan)
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 frozen
109
- # UTF-8 Strings, or nil when the options declare no header.
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
- @plan = Array(plan).dup.freeze
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
- width = (@plan.map(&:ordinal).max || -1) + 1
148
+ @plan = UNBOUND
125
149
  @reading = Runtime::Book::Reading.new(book, sheet, options.has_header, options.skip_empty_rows,
126
- @plan.map(&:packed), @plan.map(&:value_bytes), options.batch_rows, width)
127
- @header = @reading.header
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.7.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.7.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.7.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.7.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