herringbone 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/CHANGELOG.md +11 -0
- data/MANUAL.md +35 -5
- data/README.md +3 -1
- data/lib/herringbone/active_record.rb +1 -1
- data/lib/herringbone/compression.rb +23 -4
- data/lib/herringbone/inferring_writer.rb +1 -1
- data/lib/herringbone/reader/column_chunk_reader.rb +2 -0
- data/lib/herringbone/schema/merge.rb +1 -1
- data/lib/herringbone/schema.rb +95 -12
- data/lib/herringbone/types.rb +20 -12
- data/lib/herringbone/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b0226448164e89104b937f6d2009b6b8de9893fbabf06b20ab7379cdfd306ccd
|
|
4
|
+
data.tar.gz: a90c4223cf2f849fc839e3389cf40bba2d17145525bc99e57412f53cbb83f866
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe6316662f7ed600969dfd0ed7c297faf26c9694e9143b277bffca342b5593eb346989a99cce41e52ca27b3b655d069e53f8e279863b869bdb3703fcb0e7bb20
|
|
7
|
+
data.tar.gz: 9c441b9927dbd48f589bf42309e3468e55e47f056c363fc2edce0692e9be745de1db164bb313c04181e64b88b0262770843a983bff4a7b235a437deddd5e7b9e
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.8.0
|
|
6
|
+
|
|
7
|
+
- `Herringbone::MissingCodecError` explains over several lines which gem to install and that it has
|
|
8
|
+
to be required before the read or write, and names the column it could not decompress up front.
|
|
9
|
+
- `Herringbone::Schema#only(:id, :name)` and `#except(:ssn)` make a schema with some of the
|
|
10
|
+
top-level fields.
|
|
11
|
+
- `time` columns convert a `Time` to UTC when writing, as `timestamp` columns already did, instead
|
|
12
|
+
of storing its time of day in its own zone.
|
|
13
|
+
- **Breaking:** `local_timestamp` and `local_time` (`s.local_timestamp :opens_at`) write the wall
|
|
14
|
+
clock time without converting it to UTC, replacing the `utc: false` option, which now raises.
|
|
15
|
+
|
|
5
16
|
## 0.7.0
|
|
6
17
|
|
|
7
18
|
- `Herringbone.redact` and `Herringbone::Redaction#apply` take a `Herringbone::Reader` as well as an
|
data/MANUAL.md
CHANGED
|
@@ -19,6 +19,7 @@ The full reference for Herringbone. For a quick start, see the [README](README.m
|
|
|
19
19
|
- [SimpleWriter: coming from CSV](#simplewriter-coming-from-csv)
|
|
20
20
|
- [Statistics, page indexes and bloom filters](#statistics-page-indexes-and-bloom-filters)
|
|
21
21
|
- [Writing to S3](#writing-to-s3)
|
|
22
|
+
- [Writing to Google Cloud Storage](#writing-to-google-cloud-storage)
|
|
22
23
|
- [Encryption](#encryption)
|
|
23
24
|
- [Writing encrypted files](#writing-encrypted-files)
|
|
24
25
|
- [Which settings other tools read](#which-settings-other-tools-read)
|
|
@@ -212,6 +213,17 @@ The block receives the schema builder, and the blocks of `struct`, `list` and `m
|
|
|
212
213
|
builder of their own, so the block can still call your methods and read your instance variables.
|
|
213
214
|
A block that takes no parameter raises `ArgumentError`.
|
|
214
215
|
|
|
216
|
+
`only` and `except` make a schema with some of the top-level fields, keeping their order, for
|
|
217
|
+
instance to write part of a file you are reading:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
schema.only(:id, :name) # id and name
|
|
221
|
+
schema.except(:ssn) # every field but ssn
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
A name the schema has no top-level field for raises `ArgumentError`, and so does leaving out
|
|
225
|
+
every field.
|
|
226
|
+
|
|
215
227
|
### Writing with an inferred schema
|
|
216
228
|
|
|
217
229
|
`Herringbone.write(io, rows)` writes an Enumerable of rows in one go, inferring the schema from the
|
|
@@ -232,11 +244,17 @@ file and rename it.
|
|
|
232
244
|
### Column types
|
|
233
245
|
|
|
234
246
|
`boolean int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double float16 string binary
|
|
235
|
-
json bson enum uuid date int96 time timestamp decimal fixed`, plus
|
|
247
|
+
json bson enum uuid date int96 time local_time timestamp local_timestamp decimal fixed`, plus
|
|
248
|
+
`struct`, `list` and `map`;
|
|
236
249
|
`s.column :name, :int32` declares one by name. Fields are nullable unless `null: false` is given;
|
|
237
250
|
list elements unless `element_null: false`, map values unless `value_null: false`. Nested lists:
|
|
238
|
-
`s.list :matrix do |matrix| matrix.list :element, :double end`.
|
|
239
|
-
(`:millis`, `:micros`, `:nanos`)
|
|
251
|
+
`s.list :matrix do |matrix| matrix.list :element, :double end`. The time and timestamp types take `unit:`
|
|
252
|
+
(`:millis`, `:micros`, `:nanos`).
|
|
253
|
+
|
|
254
|
+
`timestamp` stores instants: a `Time` in any zone is converted to UTC, so the file means the same
|
|
255
|
+
thing wherever it was written. `local_timestamp` stores the wall clock date and time as given, with
|
|
256
|
+
no zone, for values like "the shop opens at 09:00" that are not instants. `time` and `local_time`
|
|
257
|
+
do the same for a time of day.
|
|
240
258
|
|
|
241
259
|
`enum` is a string column. `values:` restricts what may be written, and also takes a Rails-style
|
|
242
260
|
Hash (`values: Order.statuses`), in which case both labels and stored integers are accepted and
|
|
@@ -250,8 +268,8 @@ Columns accept the values Ruby and Rails code usually has at hand:
|
|
|
250
268
|
| column | accepts |
|
|
251
269
|
|---|---|
|
|
252
270
|
| `date` | `Date`, `Time`/`DateTime` (their date), `"2024-05-01"` |
|
|
253
|
-
| `timestamp` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date` (midnight UTC), ISO-8601 strings, Integers in the column's unit |
|
|
254
|
-
| `time` | `Time` (its time of day, as Rails returns for `time` columns), `"13:45:30.25"`, Integers |
|
|
271
|
+
| `timestamp`, `local_timestamp` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date` (midnight UTC), ISO-8601 strings, Integers in the column's unit |
|
|
272
|
+
| `time`, `local_time` | `Time` (its time of day, in UTC for `time`, as Rails returns for `time` columns), `"13:45:30.25"`, Integers |
|
|
255
273
|
| `json` | Strings as-is, anything else through `JSON.generate` |
|
|
256
274
|
| `string`, `enum` | Strings, Symbols, anything with `to_s` |
|
|
257
275
|
| `boolean` | `true`/`false`, `1`/`0`, `"t"`/`"f"`, `"true"`/`"false"`, `"yes"`/`"no"` |
|
|
@@ -342,6 +360,18 @@ If the block raises, the SDK aborts the multipart upload and raises `Aws::S3::Mu
|
|
|
342
360
|
so no partial object is left. S3 allows at most 10,000 parts, which with the default 5MB parts caps
|
|
343
361
|
the file at about 48GB; raise `part_size:` for bigger files.
|
|
344
362
|
|
|
363
|
+
### Writing to Google Cloud Storage
|
|
364
|
+
|
|
365
|
+
`google-cloud-storage` needs the size of an upload up front, so it can't take a stream. Use
|
|
366
|
+
[gcs_put](https://github.com/julik/gcs_put), which streams into a resumable upload of unknown size:
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
gcs_file = Google::Cloud::Storage.new.bucket("exports").file("orders.parquet", skip_lookup: true)
|
|
370
|
+
GCSPut.open_gcs_file(gcs_file) { |io| Herringbone.write(io, Order.all) }
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
If the block raises, the upload is never finalized and no object appears.
|
|
374
|
+
|
|
345
375
|
## Encryption
|
|
346
376
|
|
|
347
377
|
Herringbone reads and writes files encrypted with
|
data/README.md
CHANGED
|
@@ -46,7 +46,9 @@ end
|
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
Any Enumerable of Hashes, Structs or Arrays works too; the schema is inferred from the first 1000
|
|
49
|
-
rows. See [ActiveRecord](MANUAL.md#activerecord) and [Writing to S3](MANUAL.md#writing-to-s3).
|
|
49
|
+
rows. See [ActiveRecord](MANUAL.md#activerecord) and [Writing to S3](MANUAL.md#writing-to-s3). For
|
|
50
|
+
Google Cloud Storage, use [gcs_put](https://github.com/julik/gcs_put), see
|
|
51
|
+
[Writing to Google Cloud Storage](MANUAL.md#writing-to-google-cloud-storage).
|
|
50
52
|
|
|
51
53
|
## Reading
|
|
52
54
|
|
|
@@ -128,7 +128,7 @@ module Herringbone
|
|
|
128
128
|
when :boolean then [:boolean, {}]
|
|
129
129
|
when :binary then [:binary, {}]
|
|
130
130
|
when :date then [:date, {}]
|
|
131
|
-
when :datetime, :timestamp, :timestamptz then [:timestamp, {unit: :micros
|
|
131
|
+
when :datetime, :timestamp, :timestamptz then [:timestamp, {unit: :micros}]
|
|
132
132
|
when :time then [:time, {unit: :micros}]
|
|
133
133
|
when :json, :jsonb then [:json, {}]
|
|
134
134
|
when :uuid then [:uuid, {}]
|
|
@@ -4,7 +4,8 @@ require "zlib"
|
|
|
4
4
|
require "stringio"
|
|
5
5
|
|
|
6
6
|
module Herringbone
|
|
7
|
-
# Raised when a file uses (or a writer asks for) a codec whose library is not loaded
|
|
7
|
+
# Raised when a file uses (or a writer asks for) a codec whose library is not loaded. The message
|
|
8
|
+
# says which gem to install, and that it has to be required before the read or write.
|
|
8
9
|
class MissingCodecError < UnsupportedError
|
|
9
10
|
# @return [String] codec name (+"ZSTD"+) and name of the gem providing it (+"zstd-ruby"+)
|
|
10
11
|
attr_reader :codec, :gem_name
|
|
@@ -12,11 +13,29 @@ module Herringbone
|
|
|
12
13
|
# @param codec [String] codec name, as shown in the message
|
|
13
14
|
# @param gem_name [String] gem to add to the Gemfile
|
|
14
15
|
# @param path [String] what to require to load the gem
|
|
15
|
-
|
|
16
|
+
# @param column [String, nil] dotted path of the column being read, when there is one
|
|
17
|
+
def initialize(codec, gem_name, path, column: nil)
|
|
16
18
|
@codec = codec
|
|
17
19
|
@gem_name = gem_name
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
@path = path
|
|
21
|
+
where = column ? " to decompress column #{column}" : ""
|
|
22
|
+
super(<<~MESSAGE.chomp)
|
|
23
|
+
#{codec} compression needs the "#{gem_name}" gem#{where}, but it is not loaded.
|
|
24
|
+
Herringbone does not load optional codec gems by itself. To use #{codec}:
|
|
25
|
+
|
|
26
|
+
1. Install the gem: add `gem "#{gem_name}"` to your Gemfile, or run `gem install #{gem_name}`.
|
|
27
|
+
2. Load it with `require "#{path}"` before the read or write that needs #{codec},
|
|
28
|
+
for instance at the top of your script or in an initializer.
|
|
29
|
+
MESSAGE
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The same error, naming the column that could not be decompressed
|
|
33
|
+
#
|
|
34
|
+
# @param column [String] dotted path of the column
|
|
35
|
+
# @return [MissingCodecError]
|
|
36
|
+
# @api private
|
|
37
|
+
def for_column(column)
|
|
38
|
+
self.class.new(@codec, @gem_name, @path, column: column).tap { |e| e.set_backtrace(backtrace) if backtrace }
|
|
20
39
|
end
|
|
21
40
|
end
|
|
22
41
|
|
|
@@ -158,7 +158,7 @@ module Herringbone
|
|
|
158
158
|
type = case kind
|
|
159
159
|
when nil then Format::Type::NAMES[column.type].downcase
|
|
160
160
|
when :integer then "#{details[1] ? "int" : "uint"}#{details[0]}"
|
|
161
|
-
when :timestamp, :time then "#{kind} (#{details[0]})"
|
|
161
|
+
when :timestamp, :time then "#{"local_" unless details[1]}#{kind} (#{details[0]})"
|
|
162
162
|
when :decimal then "decimal(#{details[1]}, #{details[0]})"
|
|
163
163
|
else kind.to_s
|
|
164
164
|
end
|
|
@@ -303,6 +303,8 @@ module Herringbone
|
|
|
303
303
|
# @raise [UnsupportedError] for an unsupported codec, naming the column
|
|
304
304
|
def decompress(body, size)
|
|
305
305
|
Compression.decompress(@meta.codec, body, size)
|
|
306
|
+
rescue MissingCodecError => e
|
|
307
|
+
raise e.for_column(@column.dotted_path)
|
|
306
308
|
rescue UnsupportedError => e
|
|
307
309
|
raise e, "#{e.message} (column #{@column.dotted_path})"
|
|
308
310
|
end
|
|
@@ -283,7 +283,7 @@ module Herringbone
|
|
|
283
283
|
in [:int, 64, true] then Types.physical_attributes(:int64)
|
|
284
284
|
in [:int, bits, signed] then Types.int_type(bits, signed)
|
|
285
285
|
in [:float, bits] then Types.physical_attributes({16 => :float16, 32 => :float, 64 => :double}.fetch(bits))
|
|
286
|
-
in [:timestamp | :time => kind, unit, utc] then Types.physical_attributes(kind
|
|
286
|
+
in [:timestamp | :time => kind, unit, utc] then Types.physical_attributes(utc ? kind : :"local_#{kind}", unit: unit)
|
|
287
287
|
in [:text, :enum] then Types.physical_attributes(:enum, parquet_enum: true)
|
|
288
288
|
in [:text, kind] then Types.physical_attributes(kind)
|
|
289
289
|
in [:decimal, precision, scale] then Types.physical_attributes(:decimal, precision: precision, scale: scale)
|
data/lib/herringbone/schema.rb
CHANGED
|
@@ -470,6 +470,30 @@ module Herringbone
|
|
|
470
470
|
end
|
|
471
471
|
alias_method :&, :intersect
|
|
472
472
|
|
|
473
|
+
# The given top-level fields, in the order of this schema.
|
|
474
|
+
#
|
|
475
|
+
# schema.only(:id, :name).fields.map(&:name) # => ["id", "name"]
|
|
476
|
+
#
|
|
477
|
+
# @param names [Array<String, Symbol>] names of top-level fields
|
|
478
|
+
# @return [Schema] a new schema, sharing no nodes with this one
|
|
479
|
+
# @raise [ArgumentError] when a name is not a top-level field, or no name is given
|
|
480
|
+
def only(*names)
|
|
481
|
+
keep = field_names!(names, "only")
|
|
482
|
+
subset { |f| keep.include?(f.name) }
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Every top-level field but the given ones, in the order of this schema.
|
|
486
|
+
#
|
|
487
|
+
# schema.except(:ssn).fields.map(&:name) # => ["id", "name"]
|
|
488
|
+
#
|
|
489
|
+
# @param names [Array<String, Symbol>] names of top-level fields
|
|
490
|
+
# @return [Schema] a new schema, sharing no nodes with this one
|
|
491
|
+
# @raise [ArgumentError] when a name is not a top-level field, or all fields would be left out
|
|
492
|
+
def except(*names)
|
|
493
|
+
drop = field_names!(names, "except")
|
|
494
|
+
subset { |f| !drop.include?(f.name) }
|
|
495
|
+
end
|
|
496
|
+
|
|
473
497
|
# @return [String] one line per node, indented by depth: repetition, physical type, name and
|
|
474
498
|
# annotation
|
|
475
499
|
def inspect
|
|
@@ -501,6 +525,40 @@ module Herringbone
|
|
|
501
525
|
|
|
502
526
|
private
|
|
503
527
|
|
|
528
|
+
# @param names [Array<String, Symbol, Array>] names given to #only or #except
|
|
529
|
+
# @param method [String] the method, for the error message
|
|
530
|
+
# @return [Array<String>] the names as Strings
|
|
531
|
+
# @raise [ArgumentError] when a name is not a top-level field, or no name is given
|
|
532
|
+
def field_names!(names, method)
|
|
533
|
+
names = names.flatten.map(&:to_s)
|
|
534
|
+
raise ArgumentError, "Schema##{method} needs the names of top-level fields, e.g. schema.#{method}(:id, :name)" if names.empty?
|
|
535
|
+
missing = names - @fields.map(&:name)
|
|
536
|
+
unless missing.empty?
|
|
537
|
+
raise ArgumentError, "Schema##{method}: no top-level field #{missing.join(", ")}. " \
|
|
538
|
+
"The schema has #{@fields.map(&:name).join(", ")}"
|
|
539
|
+
end
|
|
540
|
+
names
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# @yieldparam field [Field] a top-level field
|
|
544
|
+
# @yieldreturn [Boolean] whether to keep it
|
|
545
|
+
# @return [Schema] a schema of copies of the kept fields' nodes
|
|
546
|
+
# @raise [ArgumentError] when no field is kept
|
|
547
|
+
def subset
|
|
548
|
+
nodes = @fields.select { |f| yield f }.map { |f| copy_node(f.node) }
|
|
549
|
+
raise ArgumentError, "A schema needs at least one field, and this would leave out all of #{@fields.map(&:name).join(", ")}" if nodes.empty?
|
|
550
|
+
Schema.new(Node.new(name: @root.name, repetition: :required, children: nodes))
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# @param node [Node] node to copy, with its children
|
|
554
|
+
# @return [Node] a copy sharing no nodes with the original
|
|
555
|
+
def copy_node(node)
|
|
556
|
+
Node.new(name: node.name, repetition: node.repetition, type: node.type, type_length: node.type_length,
|
|
557
|
+
converted_type: node.converted_type, logical_type: node.logical_type, scale: node.scale,
|
|
558
|
+
precision: node.precision, field_id: node.field_id, enum_values: node.enum_values,
|
|
559
|
+
children: node.children&.map { |c| copy_node(c) })
|
|
560
|
+
end
|
|
561
|
+
|
|
504
562
|
# Appends a Column for every leaf under +node+, depth-first. A non-required node adds one
|
|
505
563
|
# definition level and a repeated node one repetition level.
|
|
506
564
|
#
|
|
@@ -653,28 +711,50 @@ module Herringbone
|
|
|
653
711
|
define_method(t) { |name, **opts| column(name, t, **opts) }
|
|
654
712
|
end
|
|
655
713
|
|
|
656
|
-
# A TIME column
|
|
714
|
+
# A TIME column. A Time is converted to UTC when written, and its time of day in UTC is
|
|
715
|
+
# stored. Millis are stored as INT32, micros and nanos as INT64.
|
|
657
716
|
#
|
|
658
717
|
# @param name [String, Symbol] field name
|
|
659
718
|
# @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
|
|
660
|
-
# @param utc [Boolean] the isAdjustedToUTC flag of the logical type
|
|
661
719
|
# @param opts [Hash{Symbol => Object}] options of #column
|
|
662
720
|
# @option opts [Boolean] :null (true) whether the field is nullable
|
|
663
721
|
# @option opts [Integer, nil] :field_id (nil) field id to store in the schema
|
|
664
722
|
# @return [Node] the added node
|
|
665
723
|
# @raise [ArgumentError] for an unknown unit or a duplicate name
|
|
666
|
-
def time(name, unit: :micros,
|
|
667
|
-
# A
|
|
724
|
+
def time(name, unit: :micros, **opts) = column(name, :time, unit: unit, **opts)
|
|
725
|
+
# A TIME column for wall clock times of day. A Time is written as its time of day in its
|
|
726
|
+
# own zone, without converting it to UTC. Millis are stored as INT32, micros and nanos as INT64.
|
|
668
727
|
#
|
|
669
728
|
# @param name [String, Symbol] field name
|
|
670
729
|
# @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
|
|
671
|
-
# @param utc [Boolean] the isAdjustedToUTC flag: true for instants, false for local date-times
|
|
672
730
|
# @param opts [Hash{Symbol => Object}] options of #column
|
|
673
731
|
# @option opts [Boolean] :null (true) whether the field is nullable
|
|
674
732
|
# @option opts [Integer, nil] :field_id (nil) field id to store in the schema
|
|
675
733
|
# @return [Node] the added node
|
|
676
734
|
# @raise [ArgumentError] for an unknown unit or a duplicate name
|
|
677
|
-
def
|
|
735
|
+
def local_time(name, unit: :micros, **opts) = column(name, :local_time, unit: unit, **opts)
|
|
736
|
+
# A TIMESTAMP column, stored as INT64. A Time in any zone is converted to UTC when written,
|
|
737
|
+
# so the column holds instants.
|
|
738
|
+
#
|
|
739
|
+
# @param name [String, Symbol] field name
|
|
740
|
+
# @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
|
|
741
|
+
# @param opts [Hash{Symbol => Object}] options of #column
|
|
742
|
+
# @option opts [Boolean] :null (true) whether the field is nullable
|
|
743
|
+
# @option opts [Integer, nil] :field_id (nil) field id to store in the schema
|
|
744
|
+
# @return [Node] the added node
|
|
745
|
+
# @raise [ArgumentError] for an unknown unit or a duplicate name
|
|
746
|
+
def timestamp(name, unit: :micros, **opts) = column(name, :timestamp, unit: unit, **opts)
|
|
747
|
+
# A TIMESTAMP column for wall clock date-times, stored as INT64. A Time is written as its
|
|
748
|
+
# date and time in its own zone, without converting it to UTC, and no zone is stored.
|
|
749
|
+
#
|
|
750
|
+
# @param name [String, Symbol] field name
|
|
751
|
+
# @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
|
|
752
|
+
# @param opts [Hash{Symbol => Object}] options of #column
|
|
753
|
+
# @option opts [Boolean] :null (true) whether the field is nullable
|
|
754
|
+
# @option opts [Integer, nil] :field_id (nil) field id to store in the schema
|
|
755
|
+
# @return [Node] the added node
|
|
756
|
+
# @raise [ArgumentError] for an unknown unit or a duplicate name
|
|
757
|
+
def local_timestamp(name, unit: :micros, **opts) = column(name, :local_timestamp, unit: unit, **opts)
|
|
678
758
|
# A DECIMAL column. Up to 9 digits are stored as INT32, up to 18 as INT64, more as a
|
|
679
759
|
# FIXED_LEN_BYTE_ARRAY of the minimal width, unless +physical:+ says otherwise.
|
|
680
760
|
#
|
|
@@ -734,7 +814,6 @@ module Herringbone
|
|
|
734
814
|
# @option type_opts [Integer] :precision decimal precision
|
|
735
815
|
# @option type_opts [Integer] :scale decimal scale
|
|
736
816
|
# @option type_opts [Symbol] :unit time or timestamp unit
|
|
737
|
-
# @option type_opts [Boolean] :utc time or timestamp UTC adjustment
|
|
738
817
|
# @option type_opts [Integer] :length FIXED_LEN_BYTE_ARRAY width
|
|
739
818
|
# @yield [list] declares the struct's fields for a +:struct+ element, or exactly one field
|
|
740
819
|
# (renamed to "element") when +type+ is nil
|
|
@@ -773,7 +852,6 @@ module Herringbone
|
|
|
773
852
|
# @option type_opts [Integer] :precision decimal precision
|
|
774
853
|
# @option type_opts [Integer] :scale decimal scale
|
|
775
854
|
# @option type_opts [Symbol] :unit time or timestamp unit
|
|
776
|
-
# @option type_opts [Boolean] :utc time or timestamp UTC adjustment
|
|
777
855
|
# @option type_opts [Integer] :length FIXED_LEN_BYTE_ARRAY width
|
|
778
856
|
# @yield [map] declares the struct's fields for a +:struct+ value, or exactly one field
|
|
779
857
|
# (renamed to "value") when +value_type+ is nil
|
|
@@ -795,8 +873,8 @@ module Herringbone
|
|
|
795
873
|
# Generic column declaration: s.column :name, :int32, null: false
|
|
796
874
|
#
|
|
797
875
|
# @param name [String, Symbol] field name
|
|
798
|
-
# @param type [Symbol, String] DSL type: one of PRIMITIVES, or +:time+, +:
|
|
799
|
-
# +:decimal+, +:fixed+, +:enum+ with their options
|
|
876
|
+
# @param type [Symbol, String] DSL type: one of PRIMITIVES, or +:time+, +:local_time+,
|
|
877
|
+
# +:timestamp+, +:local_timestamp+, +:decimal+, +:fixed+, +:enum+ with their options
|
|
800
878
|
# @param null [Boolean] whether the field is nullable (optional rather than required)
|
|
801
879
|
# @param field_id [Integer, nil] field id to store in the schema
|
|
802
880
|
# @param opts [Hash{Symbol => Object}] type options
|
|
@@ -804,7 +882,6 @@ module Herringbone
|
|
|
804
882
|
# @option opts [Integer] :scale (0) decimal scale
|
|
805
883
|
# @option opts [Symbol] :physical decimal storage: +:int32+, +:int64+, +:binary+ or +:fixed+
|
|
806
884
|
# @option opts [Symbol] :unit (:micros) time or timestamp unit
|
|
807
|
-
# @option opts [Boolean] :utc (true) time or timestamp UTC adjustment
|
|
808
885
|
# @option opts [Integer] :length FIXED_LEN_BYTE_ARRAY width (required for +:fixed+)
|
|
809
886
|
# @option opts [Array<String>, Hash] :values allowed values, see #enum
|
|
810
887
|
# @option opts [Boolean] :parquet_enum (false) ENUM instead of STRING annotation, see #enum
|
|
@@ -908,8 +985,14 @@ module Herringbone
|
|
|
908
985
|
# and the rest go to Types.physical_attributes
|
|
909
986
|
# @option opts [Array<String>, Hash] :values allowed values of a string/enum column
|
|
910
987
|
# @return [Node] leaf node, not added to #nodes
|
|
911
|
-
# @raise [ArgumentError] for an unknown type or invalid type options
|
|
988
|
+
# @raise [ArgumentError] for an unknown type or invalid type options, or for +utc:+, which
|
|
989
|
+
# the local_time and local_timestamp types replace
|
|
912
990
|
def leaf_node(name, type, repetition, opts)
|
|
991
|
+
if opts.key?(:utc)
|
|
992
|
+
raise ArgumentError, "#{name}: the utc: option is gone. #{type} columns always convert Times to UTC when " \
|
|
993
|
+
"writing. To keep the wall clock time as given, without converting it, declare a local_#{type}, " \
|
|
994
|
+
"e.g. s.local_#{type} :#{name} or s.column :#{name}, :local_#{type}"
|
|
995
|
+
end
|
|
913
996
|
opts = opts.dup
|
|
914
997
|
values = opts.delete(:values)
|
|
915
998
|
attrs = Types.physical_attributes(type.to_sym, **opts)
|
data/lib/herringbone/types.rb
CHANGED
|
@@ -83,13 +83,15 @@ module Herringbone
|
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
# Physical attributes (type, logical type etc.) for a DSL type name
|
|
86
|
+
#
|
|
87
|
+
# +:time+ and +:timestamp+ hold values converted to UTC and are flagged isAdjustedToUTC;
|
|
88
|
+
# +:local_time+ and +:local_timestamp+ hold wall clock values, are not flagged and get no
|
|
89
|
+
# legacy converted type.
|
|
86
90
|
# @param type [Symbol, String] column type name, e.g. +:string+, +:int64+, +:decimal+, +:timestamp+
|
|
87
91
|
# @param opts [Hash{Symbol => Object}] type-specific options
|
|
88
92
|
# @option opts [Boolean] :parquet_enum for +:enum+, annotate as ENUM instead of STRING
|
|
89
93
|
# @option opts [Integer] :length byte length for +:fixed+ (required)
|
|
90
|
-
# @option opts [Symbol] :unit (:micros) for
|
|
91
|
-
# @option opts [Boolean] :utc (true) for +:time+ and +:timestamp+, sets +is_adjusted_to_utc+;
|
|
92
|
-
# the legacy converted type is only written when true
|
|
94
|
+
# @option opts [Symbol] :unit (:micros) for the time and timestamp types, see {time_unit}
|
|
93
95
|
# @option opts [Integer] :precision number of decimal digits for +:decimal+ (required)
|
|
94
96
|
# @option opts [Integer] :scale (0) digits after the decimal point for +:decimal+
|
|
95
97
|
# @option opts [Symbol] :physical for +:decimal+, force +:int32+, +:int64+, +:binary+ or
|
|
@@ -127,16 +129,18 @@ module Herringbone
|
|
|
127
129
|
when :int96 then {type: T::INT96}
|
|
128
130
|
when :fixed
|
|
129
131
|
{type: T::FIXED_LEN_BYTE_ARRAY, type_length: Integer(opts.fetch(:length))}
|
|
130
|
-
when :time
|
|
132
|
+
when :time, :local_time
|
|
133
|
+
utc = type.to_sym == :time
|
|
131
134
|
unit = time_unit(opts.fetch(:unit, :micros))
|
|
132
135
|
converted = {millis: C::TIME_MILLIS, micros: C::TIME_MICROS}[unit.to_sym]
|
|
133
|
-
{type: unit.millis ? T::INT32 : T::INT64, converted_type:
|
|
134
|
-
logical_type: lt(time: Format::TimeType.new(is_adjusted_to_utc:
|
|
135
|
-
when :timestamp
|
|
136
|
+
{type: unit.millis ? T::INT32 : T::INT64, converted_type: utc ? converted : nil,
|
|
137
|
+
logical_type: lt(time: Format::TimeType.new(is_adjusted_to_utc: utc, unit: unit))}
|
|
138
|
+
when :timestamp, :local_timestamp
|
|
139
|
+
utc = type.to_sym == :timestamp
|
|
136
140
|
unit = time_unit(opts.fetch(:unit, :micros))
|
|
137
141
|
converted = {millis: C::TIMESTAMP_MILLIS, micros: C::TIMESTAMP_MICROS}[unit.to_sym]
|
|
138
|
-
{type: T::INT64, converted_type:
|
|
139
|
-
logical_type: lt(timestamp: Format::TimestampType.new(is_adjusted_to_utc:
|
|
142
|
+
{type: T::INT64, converted_type: utc ? converted : nil,
|
|
143
|
+
logical_type: lt(timestamp: Format::TimestampType.new(is_adjusted_to_utc: utc, unit: unit))}
|
|
140
144
|
when :decimal
|
|
141
145
|
precision = Integer(opts.fetch(:precision))
|
|
142
146
|
scale = Integer(opts.fetch(:scale, 0))
|
|
@@ -337,7 +341,8 @@ module Herringbone
|
|
|
337
341
|
# date: Date, Time/DateTime (its calendar date), ISO-8601 String, Integer days since epoch
|
|
338
342
|
# timestamp: Time, DateTime, ActiveSupport::TimeWithZone, Date (midnight UTC), ISO-8601 String,
|
|
339
343
|
# Integer in the column's unit
|
|
340
|
-
# time: Time (its time of day), "HH:MM[:SS[.fraction]]"
|
|
344
|
+
# time: Time (its time of day, in UTC unless the column is local), "HH:MM[:SS[.fraction]]"
|
|
345
|
+
# String, Integer in the column's unit
|
|
341
346
|
# json: String (used as-is) or any other object (serialized with JSON.generate)
|
|
342
347
|
# string: String, Symbol or anything responding to to_s
|
|
343
348
|
# boolean: true/false, 1/0, "true"/"false", "t"/"f", "1"/"0", "yes"/"no"
|
|
@@ -357,7 +362,7 @@ module Herringbone
|
|
|
357
362
|
when :timestamp
|
|
358
363
|
return timestamp_writer(a, b)
|
|
359
364
|
when :time
|
|
360
|
-
return time_writer(a) if type == T::INT32 || type == T::INT64
|
|
365
|
+
return time_writer(a, b) if type == T::INT32 || type == T::INT64
|
|
361
366
|
when :json
|
|
362
367
|
return ->(v) { v.is_a?(String) ? v : JSON.generate(v) }
|
|
363
368
|
when :string, :enum
|
|
@@ -483,9 +488,11 @@ module Herringbone
|
|
|
483
488
|
# anything responding to +hour+ and +nsec+ (Time, DateTime), and raises ArgumentError or
|
|
484
489
|
# RangeError for anything else or a time past 23:59:59.
|
|
485
490
|
# @param unit [Symbol] +:millis+, +:micros+ or +:nanos+
|
|
491
|
+
# @param utc [Boolean] true to convert a Time to UTC before taking its time of day, false to
|
|
492
|
+
# take the time of day in the Time's own zone
|
|
486
493
|
# @return [Proc] one-argument lambda
|
|
487
494
|
# @raise [KeyError] for an unknown unit
|
|
488
|
-
def time_writer(unit)
|
|
495
|
+
def time_writer(unit, utc)
|
|
489
496
|
mult = UNIT_DIVISORS.fetch(unit)
|
|
490
497
|
lambda do |v|
|
|
491
498
|
return v if v.is_a?(Integer)
|
|
@@ -496,6 +503,7 @@ module Herringbone
|
|
|
496
503
|
secs = m[1].to_i * 3600 + m[2].to_i * 60 + m[3].to_i
|
|
497
504
|
else
|
|
498
505
|
raise ArgumentError, "expected a Time, got #{v.class}" unless v.respond_to?(:hour) && v.respond_to?(:nsec)
|
|
506
|
+
v = v.getutc if utc && v.respond_to?(:getutc)
|
|
499
507
|
nanos = v.nsec
|
|
500
508
|
secs = v.hour * 3600 + v.min * 60 + v.sec
|
|
501
509
|
end
|
data/lib/herringbone/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: herringbone
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Julik Tarkhanov
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-17 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: bigdecimal
|