hypertabular 0.7.0-x86_64-linux-gnu

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f12aa45e29d73c5a083eb1cff5183a171522136110ccb5c110c7975536aaa041
4
+ data.tar.gz: 52a11c73b4fcd8a5851dd1b13b5451e0302cd00ba1985ea905ac765712bcad56
5
+ SHA512:
6
+ metadata.gz: c9ea54c776214218dd62a0bbd664f5120ceefcc13ee8282126d8f586fb69c152509cba556c9b1880dc1127b1c8906b0c33a8a70b49c8a4832d2960e28e350056
7
+ data.tar.gz: c7c55e4fabf853870dc46bb9e770dd00b258f71cc8772c49ac48d6c740b2f2f770f5fceb9a04194f6e2d8e1e943f1bd2033e075aa1f0eb8d74275cb282b506f2
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Skunk Werkx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # hypertabular
2
+
3
+ Delimited text — CSV, TSV, any single-byte ASCII separator — and workbooks — XLSX and ODS —
4
+ read a batch at a time into typed columns, with a
5
+ [HyperCast](https://github.com/SkunkWerkx/HyperCast) verdict for every cell.
6
+
7
+ ```ruby
8
+ require "hypertabular"
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"]
18
+
19
+ while (batch = reader.read)
20
+ # A column at a time, decoded in one pass — nil where a cell did not cast…
21
+ ids = batch.values(0)
22
+
23
+ # …or a cell at a time, as HyperCast's union.
24
+ batch.rows.times do |row|
25
+ case batch.get(2, row)
26
+ in HyperCast::Success(value:)
27
+ puts "#{batch.values(1)[row]}: #{value}"
28
+ in HyperCast::Fault(reason:, offset:, length:)
29
+ warn "line #{batch.line(row)}: #{reason} in #{batch.raw(2, row).inspect}"
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ # A workbook reads into the same batch.
36
+ book = HyperTabular::Workbook.open("orders.xlsx")
37
+ book.sheets # => [#<data HyperTabular::SheetInfo name="Orders", hidden=false>]
38
+ sheet = book.sheet("Orders", HyperTabular::SheetOptions::DEFAULT, plan)
39
+ while (batch = sheet.read)
40
+ # …
41
+ end
42
+ ```
43
+
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.
46
+
47
+ ## The shape
48
+
49
+ The native core (`libhypertabular`) owns no memory and reads no files. `DelimitedReader`
50
+ and `Sheet` allocate the buffers — one value array and one verdict array per column, the
51
+ table that locates each cell — once, and reuse them for every batch. The core fills them in one
52
+ native call per batch, and a column comes out of its buffer in one `String#unpack`: the
53
+ boundary is crossed once per few thousand rows, not once per cell.
54
+
55
+ - **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.
59
+ - **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
61
+ states each column's door and, for numbers, its `HyperCast::NumFormat`.
62
+ - **HyperCast is the judge.** `HyperCast::Success`, `HyperCast::Fault`,
63
+ `HyperCast::NumFormat`, `HyperCast::Decimal` and the declared options (`:milliseconds`,
64
+ `:day_month_year`, `:y1904`) are the `hypercast` gem's own. A text cell means exactly
65
+ what HyperCast's door would say of the same text, a typed workbook cell is converted by
66
+ the door directly, and either comes back as the Ruby type that gem returns for it.
67
+ - **A bad value is a verdict; a broken file is an exception.** A cell that does not cast
68
+ is a `Fault` in its column and the read goes on. A record of the wrong width, input that
69
+ ends inside a quoted cell, a workbook whose container or parts cannot be read, is a
70
+ `HyperTabular::TabularError` carrying `kind`, `record`, `line`, `byte`, `expected` and
71
+ `found`, raised after every intact row before it.
72
+ - **The text of any cell is to hand.** `batch.raw(column, row)` is what the cell was cast
73
+ from, whatever its door and verdict, and a `Fault`'s span indexes it in the units
74
+ `String#[]` slices by, as HyperCast's gem does: `raw[fault.offset, fault.length]` is the
75
+ offending text.
76
+ - **A String is read in place.** An IO is read forward only, through a buffer that grows
77
+ when a record does not fit it.
78
+
79
+ ## The doors
80
+
81
+ One factory per door, named as HyperCast names them.
82
+
83
+ | Factory | Value |
84
+ |---|---|
85
+ | `Column.bool(n)` | `true` / `false` |
86
+ | `Column.i8` `i16` `i32` `i64` `u8` `u16` `u32` `u64(n, format = INVARIANT)` | `Integer` |
87
+ | `Column.f32` `f64(n, format = INVARIANT)` | `Float` |
88
+ | `Column.decimal(n, format = INVARIANT)` | `HyperCast::Decimal`, exact |
89
+ | `Column.uuid(n)` | lowercase hyphenated `String` |
90
+ | `Column.timestamp(n)` | UTC `Time`, nanosecond fidelity |
91
+ | `Column.unix(n, :seconds / :milliseconds / :microseconds / :nanoseconds)` | UTC `Time` |
92
+ | `Column.excel_serial(n, :y1900 / :y1904)` | UTC `Time` |
93
+ | `Column.date(n)` | `Date`, strict `yyyy-MM-dd` |
94
+ | `Column.date(n, order)` / `Column.date_ordered(n, order)` | `Date`, under `:year_month_day`, `:month_day_year` or `:day_month_year` |
95
+ | `Column.datetime(n, order)` | `DateTime`, zone-less |
96
+ | `Column.time(n)` | `Integer` nanoseconds since midnight |
97
+ | `Column.duration(n)` | `Rational` seconds |
98
+ | `Column.text(n)` | UTF-8 `String`, untrimmed |
99
+
100
+ ## Backends
101
+
102
+ | `HyperTabular::BACKEND` | What runs | Chosen when |
103
+ |---|---|---|
104
+ | `:native` | the core linked into a Magnus extension | a precompiled platform gem is installed — each carries an extension for Ruby 3.4 and 4.0 — or the `hypertabular-wasm` gem is linked into a ruby.wasm interpreter; see [Ruby in the browser](#ruby-in-the-browser) |
105
+ | `:fiddle` | `libhypertabular` for this platform, `dlopen`ed through Fiddle | no extension loads: the universal gem, on a Ruby or platform no platform gem covers (Intel macOS, Ruby 3.3, Ruby 4.1 and later) |
106
+
107
+ Selection happens once, at `require`, in that order. Both backends read a batch in one native
108
+ call and hand back the same bytes; everything above that crossing — the readers, the
109
+ workbook, `Batch` and every value and verdict it builds — is the same Ruby on either, and
110
+ `spec/native_backend_spec.rb` holds the two to the same answer over every cell of both corpora.
111
+ So the extension is not where a read gets faster (the crossing was already once per batch);
112
+ what it buys is a platform gem with no shared library for Fiddle to find, and Ruby in the
113
+ browser, where there is no Fiddle at all.
114
+
115
+ `HYPERTABULAR_PURE` forces `:fiddle`, as `HYPERCAST_PURE` does for hypercast. It is a testing
116
+ and diagnostic switch — CI runs the whole suite through it — read for presence, not value. A
117
+ forced backend with nothing to load does not fall through: the first reader raises, and
118
+ `HyperTabular.available?` answers `false`. Inside a platform gem, which carries no Fiddle
119
+ library, that `LoadError` names the universal gem (`gem install hypertabular --platform ruby`,
120
+ or Bundler's `force_ruby_platform`). `HyperTabular.available?` and `HyperTabular.native_version`
121
+ answer whether the core resolved, without the first read being what finds out.
122
+
123
+ **Threads.** A reader or a sheet is one thread's at a time, on either backend. The extension
124
+ runs under the GVL; Fiddle releases it for each call.
125
+
126
+ ## Ruby in the browser
127
+
128
+ ruby.wasm cannot load an extension at runtime: `rbwasm build` links the extension of every gem
129
+ in a Gemfile into the one interpreter it builds. So the browser gets a gem of its own,
130
+ `hypertabular-wasm` — the same library and the same Magnus extension, prebuilt for
131
+ `wasm32-wasip1` — which brings `hypercast-wasm`, HyperCast's own, with it. List it **instead
132
+ of** `hypertabular` in the Gemfile you build the interpreter from:
133
+
134
+ ```ruby
135
+ source "https://rubygems.org"
136
+
137
+ gem "hypertabular-wasm"
138
+ gem "js" # JavaScript interop, which a browser app almost always wants
139
+
140
+ group :development do
141
+ gem "ruby_wasm"
142
+ end
143
+ ```
144
+
145
+ ```sh
146
+ bundle install
147
+ bundle exec rbwasm build --ruby-version 4.0 -o ruby.wasm
148
+ ```
149
+
150
+ Load `ruby.wasm` with [`@ruby/wasm-wasi`](https://www.npmjs.com/package/@ruby/wasm-wasi), then
151
+ `require "/bundle/setup"` and `require "hypertabular"` as anywhere else. A workbook is read
152
+ from its bytes (`HyperTabular::Workbook.new(bytes)`) — a `fetch`ed file, an upload — and
153
+ delimited text from a String or any IO.
154
+
155
+ - **Ruby 3.4 and 4.0**, one archive each, picked by `--ruby-version`; any other minor stops the
156
+ build naming the ones the gem carries. Built and tested against ruby_wasm 2.10.
157
+ - **No Rust toolchain.** The gem's `extconf.rb` only hands rbwasm the prebuilt archive. The
158
+ first `rbwasm build` compiles Ruby itself and takes 15–20 minutes; later builds reuse it.
159
+ - **Static linking only,** into ruby.wasm's default `wasm32-unknown-wasip1` interpreter.
160
+ - **Beside HyperCast.** Every Rust extension that carries std defines a few of the same
161
+ symbols (`rust_eh_personality`, rb-sys's `ruby_abi_version`, one of std's); this gem and
162
+ `hypercast-wasm` each rename them to names of their own when CI builds the archive, and the
163
+ build fails if a newer Rust starts exporting another.
164
+
165
+ What stands behind it: CI builds each minor's archive from the commit, packs the gem the way
166
+ it ships, links it into a fresh interpreter and runs [`wasm-smoke/test.rb`](wasm-smoke/test.rb)
167
+ under Node and in headless Chrome (the forge's `hyper-build-wasm.yml`). The published gem is
168
+ packed around those attested archives.
169
+
170
+ ## Development
171
+
172
+ ```sh
173
+ cd rust && cargo cdylib # builds rust/target/release/libhypertabular.*
174
+ cd ../ruby && bundle install
175
+ HYPERTABULAR_PURE=1 bundle exec rspec # the Fiddle backend: replays both corpora
176
+ bundle exec rake native:dev # builds the Magnus extension for this Ruby, staged
177
+ bundle exec rspec # the extension, and its agreement with Fiddle
178
+ bundle exec rake docs:check
179
+ ```
@@ -0,0 +1,204 @@
1
+ module HyperTabular
2
+ # The rows one DelimitedReader#read or Sheet#read delivered, as typed columns: the same
3
+ # class for delimited text and for a sheet of a workbook.
4
+ #
5
+ # while (batch = reader.read)
6
+ # ids = batch.values(0) # a column at a time: [1, 2, nil, 4, ...]
7
+ # batch.rows.times do |row|
8
+ # case batch.get(2, row) # or a cell at a time, as HyperCast's union
9
+ # in HyperCast::Success(value:) then total += value
10
+ # in HyperCast::Fault(reason:) then warn "line #{batch.line(row)}: #{reason} in #{batch.raw(2, row).inspect}"
11
+ # end
12
+ # end
13
+ # end
14
+ #
15
+ # A batch owns what it shows — the arrays the core wrote are copied out of the reader's
16
+ # buffers as it is made — so it stays good after the reader has moved on. Values are
17
+ # HyperCast's gem's own for the same doors: true/false, Integer, Float, HyperCast::Decimal,
18
+ # a hyphenated UUID String, a UTC Time, Date, DateTime, Integer nanoseconds since midnight,
19
+ # Rational seconds, and a UTF-8 String for text.
20
+ class Batch
21
+ # The verdict of every cell that had no bytes at all: one shared, frozen Fault.
22
+ EMPTY = HyperCast::Fault.new(reason: :empty, offset: 0, length: 0)
23
+
24
+ # The doors whose column is one String#unpack directive away from its values: HyperCast's
25
+ # directive for one value, repeated down the column.
26
+ SCALARS = HyperCast::Interop::SCALARS.transform_values { |directive| "#{directive}*" }.freeze
27
+
28
+ # The doors whose value is a record: HyperCast's directive for one, how many fields it
29
+ # yields, and what builds the Ruby value HyperCast's own door returns from them.
30
+ RECORDS = HyperCast::Interop::RECORDS
31
+
32
+ # How many rows the batch holds — never zero.
33
+ attr_reader :rows
34
+
35
+ # The plan the batch was read through: column +i+ of the batch is +columns[i]+.
36
+ attr_reader :columns
37
+
38
+ # Made by a reader from what the core just wrote, copied: +values+ and +verdicts+ per
39
+ # plan column, the cell table (+per_row+ spans a row), what an unflagged span indexes
40
+ # (+base+, from +origin+) and what a flagged one does (+arena+).
41
+ def initialize(plan, rows, values, verdicts, cells, per_row, base, origin, arena, workbook:)
42
+ @columns = plan
43
+ @rows = rows
44
+ @bytes = values
45
+ @verdict_bytes = verdicts
46
+ @cells = cells
47
+ @per_row = per_row
48
+ @base = base
49
+ @origin = origin
50
+ @arena = arena.force_encoding(Encoding::UTF_8)
51
+ @workbook = workbook
52
+ @values = Array.new(plan.size)
53
+ @verdicts = Array.new(plan.size)
54
+ @faults = Array.new(plan.size)
55
+ end
56
+
57
+ # Where row +row+ came from: for delimited text the 1-based line its record starts on,
58
+ # for a sheet its 1-based row number. IndexError for a row outside the batch.
59
+ def line(row)
60
+ offset, length = @cells.unpack("L<L<", offset: (at(row) * @per_row + @per_row - 1) * 8)
61
+ @workbook ? offset : length
62
+ end
63
+
64
+ # A column's values, one per row, as a frozen Array: the Ruby value HyperCast's gem
65
+ # returns from the same door, and nil for a cell that did not cast. Decoded in one pass
66
+ # the first time it is asked for.
67
+ def values(column)
68
+ @values[column] ||= decode(@columns.fetch(column), column)
69
+ end
70
+
71
+ # A column's verdicts, one per row, as a frozen Array of HyperCast::Success and
72
+ # HyperCast::Fault. A Fault's span is in the units String#[] slices by on the cell's own
73
+ # text, as HyperCast's are: `raw(column, row)[offset, length]` is the offending text.
74
+ def verdicts(column)
75
+ @verdicts[column] ||= judge(column)
76
+ end
77
+
78
+ # The cell at (+column+, +row+) as HyperCast judged it — made for `case`/`in`:
79
+ #
80
+ # case batch.get(0, row)
81
+ # in HyperCast::Success(value:) then value
82
+ # in HyperCast::Fault(reason: :empty) then nil
83
+ # in HyperCast::Fault(reason:, offset:, length:) then raise "#{reason} at #{offset}"
84
+ # end
85
+ #
86
+ # Only this cell's verdict is built. IndexError for a column outside the plan or a row
87
+ # outside the batch.
88
+ def get(column, row)
89
+ index = at(row)
90
+ judged = @verdicts[column]
91
+ return judged[index] if judged
92
+
93
+ cell(column, index, values(column), faults(column))
94
+ end
95
+
96
+ # The text the cell at (+column+, +row+) was cast from, whatever its door and whatever
97
+ # its verdict — what a Fault's span indexes, and what to show for a value that did not
98
+ # cast. A UTF-8 String, quotes resolved. For a sheet this is a text cell's own text, and
99
+ # what a typed cell was said as when it failed its door (or went through the text door);
100
+ # a typed cell that cast has none. IndexError for a column outside the plan or a row
101
+ # outside the batch.
102
+ def raw(column, row)
103
+ plan = @columns.fetch(column)
104
+ entry = at(row) * @per_row + (@workbook ? column : plan.ordinal)
105
+ offset, length = @cells.unpack("L<L<", offset: entry * 8)
106
+ return slice(offset, length) if @workbook || length < Runtime::Delimited::SPAN_FLAG
107
+
108
+ # A quoted cell with an escaped quote in it: unescaped, as the core cast it.
109
+ quoted = @base.byteslice(@origin + offset, length & Runtime::Delimited::SPAN_LENGTH)
110
+ Runtime.unescape(quoted).force_encoding(Encoding::UTF_8)
111
+ end
112
+
113
+ # The batch in a line — not its cells.
114
+ def inspect
115
+ "#<#{self.class.name} rows=#{@rows} columns=#{@columns.size}>"
116
+ end
117
+
118
+ private
119
+
120
+ # The bytes a span names: in the arena when it is flagged, in the base when not.
121
+ def slice(offset, length)
122
+ return @arena.byteslice(offset, length & Runtime::Delimited::SPAN_LENGTH) if length >= Runtime::Delimited::SPAN_FLAG
123
+
124
+ @base.byteslice(@origin + offset, length)
125
+ end
126
+
127
+ # +row+ as an index into the batch, or an IndexError.
128
+ def at(row)
129
+ return row if row.is_a?(Integer) && row >= 0 && row < @rows
130
+
131
+ raise IndexError, "row #{row.inspect} is outside the batch (#{@rows} rows)"
132
+ end
133
+
134
+ # A column's verdict array as the core wrote it — offset, length, reason per row, flat
135
+ # — or nil when every cell cast, which one pass over the bytes answers without
136
+ # unpacking anything.
137
+ def faults(index)
138
+ flat = @faults[index]
139
+ if flat.nil?
140
+ bytes = @verdict_bytes.fetch(index)
141
+ flat = @faults[index] = bytes.count("\0") == bytes.bytesize ? false : bytes.unpack("L<*")
142
+ end
143
+ flat || nil
144
+ end
145
+
146
+ # A column's values out of its array: one String#unpack over the whole column, then the
147
+ # door's Ruby type built for each row that cast.
148
+ def decode(column, index)
149
+ door = column.door
150
+ bytes = @bytes.fetch(index)
151
+ faults = faults(index)
152
+ if (directive = SCALARS[door])
153
+ values = bytes.unpack(directive)
154
+ values.map! { |byte| byte != 0 } if door == :bool
155
+ @rows.times { |row| values[row] = nil unless faults[row * 3 + 2].zero? } if faults
156
+ elsif door == :text
157
+ values = texts(bytes.unpack("L<*"), faults)
158
+ else
159
+ directive, width, build = RECORDS.fetch(door)
160
+ fields = bytes.unpack(directive * @rows)
161
+ values = Array.new(@rows) do |row|
162
+ build.call(fields, row * width) if faults.nil? || faults[row * 3 + 2].zero?
163
+ end
164
+ end
165
+ values.freeze
166
+ end
167
+
168
+ # A text column's values: each span sliced out of the input or the shared strings, or —
169
+ # flagged — out of the arena.
170
+ def texts(spans, faults)
171
+ Array.new(@rows) do |row|
172
+ slice(spans[row * 2], spans[row * 2 + 1]) if faults.nil? || faults[row * 3 + 2].zero?
173
+ end
174
+ end
175
+
176
+ # A column's verdicts: a Success around each value, a Fault where the core says so.
177
+ def judge(column)
178
+ values = values(column)
179
+ faults = faults(column)
180
+ return values.map { |value| HyperCast::Success.new(value: value) }.freeze if faults.nil?
181
+
182
+ Array.new(@rows) { |row| cell(column, row, values, faults) }.freeze
183
+ end
184
+
185
+ # One cell's verdict, from its column's values and the core's verdict array.
186
+ def cell(column, row, values, faults)
187
+ reason = faults.nil? ? 0 : faults[row * 3 + 2]
188
+ return HyperCast::Success.new(value: values[row]) if reason.zero?
189
+
190
+ fault(column, row, reason, faults[row * 3], faults[row * 3 + 1])
191
+ end
192
+
193
+ # One Fault, its span moved from the bytes the core counts in to the characters
194
+ # String#[] slices by — an identity unless the cell's text has a multi-byte character.
195
+ def fault(column, row, reason, offset, length)
196
+ return EMPTY if reason == 1 && offset.zero? && length.zero?
197
+
198
+ unless offset.zero? && length.zero?
199
+ offset, length = HyperCast::Interop.characters(raw(column, row), offset, length)
200
+ end
201
+ HyperCast::Interop.fault(reason, offset, length)
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,170 @@
1
+ module HyperTabular
2
+ # The doors a column can be cast through, by the code the core knows each as: HyperCast's
3
+ # twenty-one, named as its gem names them, plus :text for the bytes themselves.
4
+ DOORS = {
5
+ bool: 1, i8: 2, i16: 3, i32: 4, i64: 5, u8: 6, u16: 7, u32: 8, u64: 9, f32: 10, f64: 11,
6
+ uuid: 12, timestamp: 13, unix: 14, date: 15, time: 16, duration: 17, text: 18, decimal: 19,
7
+ date_ordered: 20, datetime: 21, excel_serial: 22
8
+ }.freeze
9
+
10
+ # One output column of a plan: which source column it reads, the door it casts through,
11
+ # what that door declares, and — for the numeric doors — the notation. A plan is a
12
+ # projection: a forty-column file can be read into five typed columns, in any order, and
13
+ # a source column can be read through more than one door.
14
+ #
15
+ # Built by the factory named after its door, never guessed from the data:
16
+ #
17
+ # plan = [
18
+ # HyperTabular::Column.i32(0),
19
+ # HyperTabular::Column.text(1),
20
+ # HyperTabular::Column.decimal(2, eurozone),
21
+ # HyperTabular::Column.date(3, :day_month_year),
22
+ # HyperTabular::Column.unix(4, :milliseconds)
23
+ # ]
24
+ #
25
+ # +ordinal+ is the zero-based source column; past a record's last cell it reads as empty.
26
+ # +door+ is a key of DOORS. +declared+ is the Symbol the door was declared with — a Unix
27
+ # precision, a date order, an Excel epoch, as HyperCast names them — or nil. +format+ is
28
+ # the HyperCast::NumFormat of a numeric door, or nil.
29
+ Column = Data.define(:ordinal, :door, :declared, :format) do
30
+ # Checks the column as the caller bug it would otherwise become at the first read: an
31
+ # ordinal that is not a non-negative Integer or a format that is not a
32
+ # HyperCast::NumFormat (ArgumentError), an unknown door or an undeclared option
33
+ # (KeyError, as HyperCast raises for the same).
34
+ def initialize(ordinal:, door:, declared: nil, format: nil)
35
+ raise ArgumentError, "ordinal must be an Integer in 0..#{Column::MAX_ORDINAL}; got #{ordinal.inspect}" unless
36
+ ordinal.is_a?(Integer) && ordinal.between?(0, Column::MAX_ORDINAL)
37
+
38
+ DOORS.fetch(door)
39
+ if (options = Column::DECLARES[door])
40
+ options.fetch(declared)
41
+ elsif !declared.nil?
42
+ raise ArgumentError, "the #{door} door declares nothing; got #{declared.inspect}"
43
+ end
44
+ if Column::NUMERIC.include?(door)
45
+ format = HyperCast::NumFormat::INVARIANT if format.nil?
46
+ raise ArgumentError, "format must be a HyperCast::NumFormat; got #{format.inspect}" unless
47
+ format.is_a?(HyperCast::NumFormat)
48
+ elsif !format.nil?
49
+ raise ArgumentError, "the #{door} door reads no numeric format"
50
+ end
51
+
52
+ super(ordinal: ordinal, door: door, declared: declared, format: format)
53
+ end
54
+
55
+ # A boolean column: HyperCast's boolean lexicon, as true or false.
56
+ def self.bool(ordinal) = new(ordinal: ordinal, door: :bool)
57
+
58
+ # A signed 8-bit column, as an Integer. Invariant notation unless one is declared.
59
+ def self.i8(ordinal, format = HyperCast::NumFormat::INVARIANT) = new(ordinal: ordinal, door: :i8, format: format)
60
+
61
+ # A signed 16-bit column, as an Integer. Invariant notation unless one is declared.
62
+ def self.i16(ordinal, format = HyperCast::NumFormat::INVARIANT) = new(ordinal: ordinal, door: :i16, format: format)
63
+
64
+ # A signed 32-bit column, as an Integer. Invariant notation unless one is declared.
65
+ def self.i32(ordinal, format = HyperCast::NumFormat::INVARIANT) = new(ordinal: ordinal, door: :i32, format: format)
66
+
67
+ # A signed 64-bit column, as an Integer. Invariant notation unless one is declared.
68
+ def self.i64(ordinal, format = HyperCast::NumFormat::INVARIANT) = new(ordinal: ordinal, door: :i64, format: format)
69
+
70
+ # An unsigned 8-bit column, as an Integer. Invariant notation unless one is declared.
71
+ def self.u8(ordinal, format = HyperCast::NumFormat::INVARIANT) = new(ordinal: ordinal, door: :u8, format: format)
72
+
73
+ # An unsigned 16-bit column, as an Integer. Invariant notation unless one is declared.
74
+ def self.u16(ordinal, format = HyperCast::NumFormat::INVARIANT) = new(ordinal: ordinal, door: :u16, format: format)
75
+
76
+ # An unsigned 32-bit column, as an Integer. Invariant notation unless one is declared.
77
+ def self.u32(ordinal, format = HyperCast::NumFormat::INVARIANT) = new(ordinal: ordinal, door: :u32, format: format)
78
+
79
+ # An unsigned 64-bit column, as the true unsigned Integer. Invariant notation unless
80
+ # one is declared.
81
+ def self.u64(ordinal, format = HyperCast::NumFormat::INVARIANT) = new(ordinal: ordinal, door: :u64, format: format)
82
+
83
+ # An IEEE single column, widened losslessly to a Float. Invariant notation unless one
84
+ # is declared.
85
+ def self.f32(ordinal, format = HyperCast::NumFormat::INVARIANT) = new(ordinal: ordinal, door: :f32, format: format)
86
+
87
+ # An IEEE double column, as a Float. Invariant notation unless one is declared.
88
+ def self.f64(ordinal, format = HyperCast::NumFormat::INVARIANT) = new(ordinal: ordinal, door: :f64, format: format)
89
+
90
+ # An exact decimal column, as a HyperCast::Decimal; no float is ever formed. Invariant
91
+ # notation unless one is declared.
92
+ def self.decimal(ordinal, format = HyperCast::NumFormat::INVARIANT)
93
+ new(ordinal: ordinal, door: :decimal, format: format)
94
+ end
95
+
96
+ # A UUID column, as the lowercase hyphenated String.
97
+ def self.uuid(ordinal) = new(ordinal: ordinal, door: :uuid)
98
+
99
+ # An RFC 3339 instant column, as a UTC Time at full nanosecond fidelity.
100
+ def self.timestamp(ordinal) = new(ordinal: ordinal, door: :timestamp)
101
+
102
+ # A Unix-epoch column at the declared precision (:seconds, :milliseconds,
103
+ # :microseconds, :nanoseconds) — never guessed from magnitude — as a UTC Time.
104
+ def self.unix(ordinal, precision) = new(ordinal: ordinal, door: :unix, declared: precision)
105
+
106
+ # An Excel date-serial column under the declared date system (:y1900, :y1904), as a
107
+ # UTC Time.
108
+ def self.excel_serial(ordinal, epoch) = new(ordinal: ordinal, door: :excel_serial, declared: epoch)
109
+
110
+ # A calendar date column, as a Date. With no order declared: the strict ISO 8601
111
+ # yyyy-MM-dd door. With one (:year_month_day, :month_day_year, :day_month_year): the
112
+ # date_ordered door, which also reads the separated forms — the same split
113
+ # HyperCast.date makes.
114
+ def self.date(ordinal, order = nil)
115
+ order.nil? ? new(ordinal: ordinal, door: :date) : date_ordered(ordinal, order)
116
+ end
117
+
118
+ # A separated calendar date column under the declared field order, as a Date.
119
+ def self.date_ordered(ordinal, order) = new(ordinal: ordinal, door: :date_ordered, declared: order)
120
+
121
+ # A zone-less civil date-time column under the declared field order, as a DateTime
122
+ # whose +00:00 offset is a carrier artifact, not data — the text named no zone.
123
+ def self.datetime(ordinal, order) = new(ordinal: ordinal, door: :datetime, declared: order)
124
+
125
+ # A 24-hour time-of-day column, as an exact Integer of nanoseconds since midnight.
126
+ def self.time(ordinal) = new(ordinal: ordinal, door: :time)
127
+
128
+ # A duration column, as exact Rational seconds.
129
+ def self.duration(ordinal) = new(ordinal: ordinal, door: :duration)
130
+
131
+ # A text column: the cell's bytes themselves, untrimmed, quotes resolved, as a UTF-8
132
+ # String. A cell with no bytes at all is the one way text fails (an :empty Fault).
133
+ def self.text(ordinal) = new(ordinal: ordinal, door: :text)
134
+
135
+ # Bytes one value of this column's door takes in a column buffer.
136
+ def value_bytes
137
+ Column::VALUE_BYTES.fetch(door)
138
+ end
139
+
140
+ # The 44 little-endian bytes of the core's ColumnSpec: ordinal, door code, what the
141
+ # door declares (numbered as HyperCast numbers it), then the notation in HyperCast's
142
+ # own packed form — all zeros, which the core reads as invariant, for a door that
143
+ # reads none.
144
+ def packed
145
+ param = declared.nil? ? 0 : Column::DECLARES.fetch(door).fetch(declared)
146
+ [ordinal, DOORS.fetch(door), param].pack("L<3") + (format.nil? ? Column::NO_FORMAT : format.packed)
147
+ end
148
+ end
149
+
150
+ # The widest source ordinal a column can name.
151
+ Column::MAX_ORDINAL = (1 << 31) - 1
152
+
153
+ # The doors that read a HyperCast::NumFormat: the integers, the reals and the decimal.
154
+ Column::NUMERIC = %i[i8 i16 i32 i64 u8 u16 u32 u64 f32 f64 decimal].freeze
155
+
156
+ # What the doors that declare something declare, as HyperCast's own tables number it.
157
+ Column::DECLARES = {
158
+ unix: HyperCast::UNIX_PRECISIONS,
159
+ excel_serial: HyperCast::EXCEL_EPOCHS,
160
+ date_ordered: HyperCast::DATE_ORDERS,
161
+ datetime: HyperCast::DATE_ORDERS
162
+ }.freeze
163
+
164
+ # Bytes per value in a column buffer, by door (rust/src/kernel/abi.rs, ColumnBuffer):
165
+ # HyperCast's for its doors, and a text cell's span — two u32s — for this one's.
166
+ Column::VALUE_BYTES = HyperCast::Interop::VALUE_BYTES.merge(text: 8).freeze
167
+
168
+ # The notation of a door that reads none: thirty-two zero bytes.
169
+ Column::NO_FORMAT = ("\0" * 32).b.freeze
170
+ end