herringbone 0.2.0 → 0.4.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: b6e9b4e12f2b83688e6ea7be2752dd389cd1a338489834160efb0df5f796916e
4
- data.tar.gz: c69303ac028259092e2f22c0f5d635f0e428ae1950c1b4b42d4a6379fd6a5a87
3
+ metadata.gz: 826898400d423a36ccd89740d408da3d367726cf84c6cf33c4067a3c54b8861d
4
+ data.tar.gz: 0667170a3ede8376299aff04b4081259533c8877f9823ad8fcc9aee916a86650
5
5
  SHA512:
6
- metadata.gz: 00a7d9246cb73211dcd5c46a5b0dab15b20b0fe1dc0026a2f02dcf3a9e13a11c91b8632bf1891fdd2f5a8deccf42f1c46918ee26ed9bf881208338f731cd12b6
7
- data.tar.gz: 91edc03478f1d9aa24f4d40527b8d2ac8cbed53047e3870e2b17d52ba252bb29a354035f5f072105adf0122bcf93e24c424747552423f9f7ba345fedb98b4219
6
+ metadata.gz: 21984e5695bf75e086fa091a4c69456edd4fc7ef21ea2d73c89e0ad3de1316238d2f66cf9d8c86183c5e962a1ce965aaeac8036dc1a5c1a43aa6a3b85c983405
7
+ data.tar.gz: 9377178bd66028014087cc55ed255c4abc2ed1c0217cdfbd86d5f9fe647a4ac0d741a973415f393c31fc7af83d653ce14df33d7a7629dee69aaed554e1fa5719
data/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.4.0
6
+
7
+ - herringbone is now Ractor-enabled! Reading and writing work from any Ractor on Ruby 3.1+, and schemas can be passed to Ractors with
8
+ - **Breaking:** the optional gems (`zstd-ruby`, `brotli`, `snappy`, `xxhash`, `numo-narray-alt`)
9
+ are used only when loaded: require them (`Bundler.require` does) instead of relying on
10
+ Herringbone to require them on first use.
11
+ - Files with LZO-compressed pages (written by parquet-mr with hadoop-lzo) can be read. Writing
12
+ LZO is not supported.
13
+ - `Herringbone::SimpleWriter`: CSV-style writing with `headers!` and Array rows, types inferred.
14
+ - `Herringbone.write` without `schema:` reads the rows once, holding back only the first 1000 for
15
+ inference, so cursors and one-shot Enumerators no longer lose rows.
16
+ - `Herringbone.write(io, rows) { json :payload }` declares fields that replace inferred ones.
17
+ - `Herringbone::SchemaMismatch` (an `EncodeError`) explains a row that doesn't fit an inferred
18
+ schema and stops the write; `EncodeError` has `row`, `column` and `value`.
19
+ - `herringbone inspect --format=html` opens the page in the browser when run in a terminal.
20
+ `Ractor.make_shareable(schema)`.
21
+ - **Breaking:** `herringbone inspect` takes `--format=text|json|html` instead of `--json`/`--html`.
22
+
23
+ ## 0.3.0
24
+
25
+ - `read(as: :numo)` and `each_batch(as: :numo)` return Numo arrays (optional `numo-narray-alt`).
26
+ - The `snappy` gem is used for Snappy when installed: 27x faster compression, 12x faster decompression.
27
+ - Writes are forward-only, so files can be streamed into S3 multipart uploads.
28
+ - YARD documentation for the whole library.
29
+
30
+ ## 0.2.0
31
+
32
+ - Streaming reads: pages are decoded incrementally and rows assembled in batches.
33
+ - `each_batch(as: :columns)` and `read(as: :columns)` for column-order reads.
34
+ - `where:`, `from:` and `limit:` skip row groups and pages using statistics, bloom filters and the
35
+ page index.
36
+ - Page indexes (ColumnIndex and OffsetIndex) are written for every column chunk.
37
+ - Bloom filters are written and read, using the `xxhash` gem when it is installed.
38
+ - `Herringbone::Inspector`, an HTML visualizer and `herringbone inspect`: page headers, page
39
+ indexes, CRC verification and the decoded ARROW:schema, without decompressing any data.
40
+ - `Herringbone.write` takes ActiveRecord models and relations.
41
+ - `zstd-ruby` and `brotli` are optional; a missing codec raises `MissingCodecError`.
42
+ - **Breaking:** the public API was simplified: `Reader.new(io)` and `Writer.open(io, schema)`
43
+ only take IOs, Hash schemas and `Herringbone.export`/`open`/`read` are gone, and `DecodeError`
44
+ is now `FormatError`.
45
+
46
+ ## 0.1.0
47
+
48
+ - Pure-Ruby Parquet reader and writer, with no Thrift gem or native extensions.
49
+ - Full nesting support (structs, lists, maps) via Dremel shredding and assembly.
50
+ - All standard encodings, v1 and v2 data pages, Snappy, LZ4 and GZIP in Ruby, ZSTD and Brotli via gems.
51
+ - Schemas from a block DSL, inferred from rows, or built from ActiveRecord models.
52
+ - Writer coercions for Rails values, byte-based row groups and compact buffers.
data/README.md CHANGED
@@ -3,30 +3,71 @@
3
3
  A pure-Ruby reader and writer for [Apache Parquet](https://parquet.apache.org/) files.
4
4
 
5
5
  - No Thrift gem and no native extensions of its own: the Thrift compact protocol, all encodings
6
- and the Snappy and LZ4 codecs are implemented in Ruby (ZSTD and Brotli use optional gems)
6
+ and the Snappy and LZ4 codecs are implemented in Ruby (ZSTD and Brotli use optional gems).
7
+ LZO, from Hadoop-era files, can be read but not written
7
8
  - Full nesting support (structs, lists, maps, any depth) via Dremel record shredding/assembly
8
9
  - Reads files from parquet-mr, Arrow, Spark, Impala, DuckDB, Rust writers etc.
10
+ - Optimized reads with batches and pages
9
11
  - Ruby 3.0+
10
12
 
13
+ ## Diving in: dumping records in Rails
14
+
15
+ Stream a relation straight into S3, no temp file needed:
16
+
17
+ ```ruby
18
+ s3 = Aws::S3::TransferManager.new
19
+ s3.upload_stream(bucket: "exports", key: "payments.parquet") do |io|
20
+ # Schema will be auto-inferred, find_each will be used automatically
21
+ Herringbone.write(io, Payment.where(status: "settled", created_at: 1.month.ago..))
22
+ end
23
+ ```
24
+
11
25
  ## Installation
12
26
 
13
27
  ```ruby
14
28
  gem "herringbone"
15
- gem "zstd-ruby" # optional: ZSTD (faster writes and smaller files than the default Snappy)
16
- gem "brotli" # optional: Brotli
17
- gem "xxhash" # optional: faster bloom filters
18
29
  ```
19
30
 
20
- The only dependency is `bigdecimal`. Snappy, LZ4 and GZIP always work. `Herringbone.codecs` lists
21
- the codecs this process can use, e.g. `[:none, :snappy, :gzip, :lz4, :lz4_hadoop, :zstd]`. Using
22
- a missing one raises `Herringbone::MissingCodecError` naming the gem to add: a writer raises it
23
- before writing anything, a reader when it reaches the first such page (the schema and metadata
24
- are still readable). LZO is not supported.
31
+ Add these libraries to speed up and enable certain functionality:
32
+
33
+ ```ruby
34
+ gem "snappy" # native Snappy, 2-3x faster reads and writes of typical files
35
+ gem "zstd-ruby" # ZSTD
36
+ gem "brotli" # Brotli
37
+ gem "xxhash" # faster bloom filters
38
+ gem "numo-narray-alt" # read(as: :numo)
39
+ ```
40
+
41
+ Herringbone never requires them itself: it uses whichever are loaded. Bundler.require (as in Rails)
42
+ loads them, otherwise require them yourself:
43
+
44
+ ```ruby
45
+ require "herringbone"
46
+ require "zstd-ruby"
47
+ require "numo/narray"
48
+ ```
49
+
50
+ On Ruby 3.1 and later, Herringbone works from any Ractor. Pass a schema to a Ractor with
51
+ `Ractor.make_shareable(schema)`. Rows made shareable the same way reach a writing Ractor by
52
+ reference, without being copied:
53
+
54
+ ```ruby
55
+ writer = Ractor.new("out.parquet", Ractor.make_shareable(schema)) do |path, schema|
56
+ File.open(path, "wb") do |f|
57
+ Herringbone::Writer.open(f, schema) do |w|
58
+ while (row = Ractor.receive) != :done
59
+ w << row
60
+ end
61
+ end
62
+ end
63
+ end
64
+ rows.each { |row| writer.send(Ractor.make_shareable(row)) }
65
+ writer.send(:done)
66
+ ```
25
67
 
26
68
  ## Reading
27
69
 
28
- Herringbone never opens files by path: readers take a random-access IO (a `File` opened with
29
- `"rb"`, `StringIO`, `Tempfile`...), which stays open and belongs to the caller.
70
+ Herringbone can read from any IO-ish object with random access (the IO should be seekable).
30
71
 
31
72
  ```ruby
32
73
  require "herringbone"
@@ -76,6 +117,34 @@ are exact. On a 1M-row file with 20k-row pages, looking up one `id` takes 0.1 s
76
117
  Filters help most on columns the data is sorted or clustered by. `reader.scan_plan(where: ...)`
77
118
  shows which row groups and row ranges a read would touch, without reading them.
78
119
 
120
+ ### Numo arrays
121
+
122
+ `as: :numo` returns (or yields, with `each_batch`) a Hash of column name => Numo array, and takes
123
+ the same `columns:`, `where:`, `from:` and `limit:`. Add `gem "numo-narray-alt"` (or
124
+ `numo-narray`) to your Gemfile and `require "numo/narray"`: without it, `as: :numo` raises
125
+ `Herringbone::UnsupportedError` naming the gem.
126
+
127
+ ```ruby
128
+ cols = reader.read(as: :numo, columns: %w[id amount]) # { "id" => Numo::Int64, "amount" => Numo::DFloat }
129
+ df = Rover::DataFrame.new(reader.read(as: :numo)) # no conversion needed
130
+ ```
131
+
132
+ Flat numeric and boolean columns are decoded from the page bytes without a Ruby object per value:
133
+ two numeric columns of 1M uncompressed rows read in about 25 ms, against 50 ms with `as: :columns`.
134
+
135
+ | Parquet | Numo |
136
+ |---|---|
137
+ | INT32, INT64 (also TIME) | `Int32`, `Int64` |
138
+ | INT(8/16) signed; INT(8/16/32/64) unsigned | `Int8`, `Int16`; `UInt8` … `UInt64` |
139
+ | FLOAT, FLOAT16, DOUBLE | `SFloat`, `SFloat`, `DFloat` (nulls are NaN) |
140
+ | integers with nulls | `DFloat` with NaN (exact up to 2**53) |
141
+ | BOOLEAN | `Bit`; with nulls `RObject` of true/false/nil |
142
+ | list of numbers, every row the same length and no nulls | 2-D `[rows, length]` (e.g. embeddings) |
143
+ | strings, binary, decimals, dates, timestamps, UUIDs, structs, maps, other lists | `RObject` of the values `read` returns |
144
+
145
+ Whether a column has nulls (or a list column is rectangular) is decided from the rows read, so
146
+ with `each_batch` it can differ between batches.
147
+
79
148
  ## Writing
80
149
 
81
150
  ```ruby
@@ -108,12 +177,16 @@ end
108
177
 
109
178
  `Herringbone.write(io, rows)` writes an Enumerable of rows in one go, inferring the schema from the
110
179
  first 1000 rows unless `schema:` is given; fields declared in a block replace inferred ones:
111
- `Herringbone.write(io, rows, schema: Herringbone::Schema.infer(rows) { json :payload })`.
180
+ `Herringbone.write(io, rows) { json :payload }`. The rows are iterated once, holding back only
181
+ those first 1000, so lazy Enumerators and cursors that can't be rewound work. A later row that
182
+ doesn't fit the inferred types raises `Herringbone::SchemaMismatch`, which explains what was
183
+ inferred and how to declare the column, and leaves the file unfinished.
112
184
 
113
185
  The writer writes to any IO that responds to `#write` (a `File`, `StringIO`, `Tempfile`, socket or
114
- pipe), sequentially, and never seeks, rewinds or closes it. If the `Writer.open` block raises (or
115
- `#abort` is called), no footer is written and what was written so far is left for you to discard.
116
- To replace a file only once it is complete, write to a temporary file and rename it.
186
+ pipe), sequentially, and never seeks, rewinds or closes it (it does switch it to binary mode). If
187
+ the `Writer.open` block raises (or `#abort` is called), no footer is written and what was written
188
+ so far is left for you to discard. To replace a file only once it is complete, write to a temporary
189
+ file and rename it.
117
190
 
118
191
  Column types: `boolean int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double float16
119
192
  string binary json bson enum uuid date int96 time timestamp decimal fixed`, plus `struct`, `list`
@@ -159,6 +232,24 @@ Writer options:
159
232
  | `metadata` | `{}` | footer key/value metadata, read back with `reader.metadata` |
160
233
  | `bloom_filters` | none | `true`, an Array of column paths, or `{ "path" => { ndv:, fpp:, max_bytes: } }` |
161
234
 
235
+ ### Coming from CSV
236
+
237
+ `SimpleWriter` writes like the CSV gem: name the columns, then append Arrays. The types are
238
+ inferred from the first 1000 rows, as above.
239
+
240
+ ```ruby
241
+ File.open("people.parquet", "wb") do |file|
242
+ Herringbone::SimpleWriter.open(file) do |sw|
243
+ sw.headers!(:id, :name, :age)
244
+ sw << [123, "John", 12]
245
+ sw << { id: 124, name: "Jane" } # Hashes work too
246
+ end
247
+ end
248
+ ```
249
+
250
+ Unlike CSV, headers are required. A column that holds more than one type can be declared up front:
251
+ `Herringbone::SimpleWriter.new(io) { string :code }` (then call `close` when done).
252
+
162
253
  ### Statistics, page indexes and bloom filters
163
254
 
164
255
  Every column chunk gets min/max/null-count statistics and the Parquet page index (per-page min/max
@@ -171,10 +262,29 @@ emails or UUIDs; a bloom filter can. They are off by default. `bloom_filters: ["
171
262
  (or `true` for every non-boolean column) writes Parquet's split block bloom filters, sized from
172
263
  each row group's distinct values at a false positive probability of 1% (`fpp:`, up to `max_bytes:`,
173
264
  default 1MB) unless `ndv:` gives the number of distinct values. Nested leaves are named by their
174
- dotted path (`"tags.list.element"`). Hashing is pure Ruby unless the `xxhash` gem is installed:
265
+ dotted path (`"tags.list.element"`). Hashing is pure Ruby unless the `xxhash` gem is loaded:
175
266
  a million rows take about 1.2 s longer to write with a filter on an INT64 column and 2.8 s longer
176
267
  with one on a ~22-byte string column, and about 0.5 s longer with `xxhash`.
177
268
 
269
+ ### Writing to S3
270
+
271
+ Parquet keeps its metadata in a footer, so a file can be streamed into an S3 multipart upload
272
+ without a local copy, using `upload_stream` from `aws-sdk-s3`:
273
+
274
+ ```ruby
275
+ s3 = Aws::S3::TransferManager.new # aws-sdk-s3 1.197+; before that, Aws::S3::Object#upload_stream
276
+ s3.upload_stream(bucket: "exports", key: "events.parquet", part_size: 16 * 1024 * 1024) do |io|
277
+ Herringbone::Writer.open(io, schema) do |w|
278
+ events.each { |event| w << event }
279
+ end
280
+ end
281
+ s3.upload_stream(bucket: "exports", key: "orders.parquet") { |io| Herringbone.write(io, Order.all) }
282
+ ```
283
+
284
+ If the block raises, the SDK aborts the multipart upload and raises `Aws::S3::MultipartUploadError`,
285
+ so no partial object is left. S3 allows at most 10,000 parts, which with the default 5MB parts caps
286
+ the file at about 48GB; raise `part_size:` for bigger files.
287
+
178
288
  ## ActiveRecord
179
289
 
180
290
  `Herringbone.write` also takes a model or relation, which it reads with `find_each`, using a schema
@@ -229,7 +339,7 @@ follows `Model.columns`.
229
339
 
230
340
  > The inspector and its HTML view are modelled on
231
341
  > **[Parquet X-ray](https://huggingface.co/spaces/cfahlgren1/parquet-xray) by cfahlgren1** —
232
- > the design and the idea are theirs. Go check it out.
342
+ > the design and the idea are theirs. Go check it out. It is amazing!
233
343
 
234
344
  `Herringbone::Inspector` examines a file using only its footer, page headers, page indexes and
235
345
  bloom filter headers. Nothing is decompressed, so it is fast on big files and works for ZSTD and
@@ -252,14 +362,20 @@ CRCs; the results then appear in `summary`, `report`, `to_h` and `to_html`.
252
362
  ## Command line
253
363
 
254
364
  ```
255
- bin/herringbone cat FILE [N] # rows as JSON lines
256
- bin/herringbone inspect FILE [--pages] # text report (--pages lists every page header)
257
- bin/herringbone inspect FILE --json # everything as JSON
258
- bin/herringbone inspect FILE --html > out.html # the HTML page
365
+ bin/herringbone cat FILE [N] # rows as JSON lines
366
+ bin/herringbone inspect FILE [--pages] # text report (--pages lists every page header)
367
+ bin/herringbone inspect FILE --format=json # everything as JSON
368
+ bin/herringbone inspect FILE --format=html # the HTML page, opened in your browser
369
+ bin/herringbone inspect FILE --format=html > out.html # the HTML page, saved
259
370
  ```
260
371
 
261
372
  Add `--verify-checksums` to any `inspect` form to check page CRCs.
262
373
 
374
+ `--format` is `text` (the default), `json` or `html`; `--pages` only goes with `text`.
375
+ In a terminal, `--format=html` writes the page to a temp file, prints its path and opens it with `open` on
376
+ macOS, `start` on Windows or `xdg-open` elsewhere. When stdout is redirected or piped it prints the
377
+ page instead.
378
+
263
379
  ## Supported format features
264
380
 
265
381
  - Encodings (read and write): PLAIN, PLAIN_DICTIONARY/RLE_DICTIONARY, RLE, DELTA_BINARY_PACKED,
data/bin/herringbone CHANGED
@@ -3,14 +3,21 @@
3
3
 
4
4
  # Usage:
5
5
  # herringbone cat FILE [N] print rows as JSON lines (optionally only the first N)
6
- # herringbone inspect FILE [--pages] print the schema, row groups, column chunks (and every page header)
7
- # herringbone inspect FILE --json print all of it as JSON
8
- # herringbone inspect FILE --html print a self-contained HTML page showing the file's layout
6
+ # herringbone inspect FILE [--pages] print the schema, row groups, column chunks (and every
7
+ # page header); same as --format=text
8
+ # herringbone inspect FILE --format=json print all of it as JSON
9
+ # herringbone inspect FILE --format=html write a self-contained HTML page showing the file's layout
10
+ # and open it in the browser (printed when stdout is redirected)
9
11
  # add --verify-checksums to any inspect form to read page bodies and check their CRCs
10
12
  # `inspect` reads only the footer, page headers and page indexes: no values are decompressed.
11
13
  $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
12
14
  require "herringbone"
13
15
  require "json"
16
+ %w[zstd-ruby brotli snappy xxhash].each do |lib|
17
+ require lib
18
+ rescue LoadError
19
+ end
20
+ require "tmpdir"
14
21
 
15
22
  def jsonable(v)
16
23
  case v
@@ -25,8 +32,20 @@ def jsonable(v)
25
32
  end
26
33
  end
27
34
 
35
+ # Opens a file with the platform's default application
36
+ # @return [Boolean] whether the opener could be started
37
+ def open_with_default_app(path)
38
+ command = case RbConfig::CONFIG["host_os"]
39
+ when /darwin/ then ["open", path]
40
+ when /mswin|mingw|cygwin/ then ["cmd", "/c", "start", "", path]
41
+ else ["xdg-open", path]
42
+ end
43
+ !!system(*command, out: File::NULL, err: File::NULL, exception: false)
44
+ end
45
+
28
46
  USAGE = "usage: herringbone cat FILE [N]\n" \
29
- " herringbone inspect FILE [--pages | --json | --html] [--verify-checksums]"
47
+ " herringbone inspect FILE [--format=text|json|html] [--pages] [--verify-checksums]\n" \
48
+ " (--pages only with --format=text)"
30
49
 
31
50
  command, *rest = ARGV
32
51
  flags, args = rest.partition { |a| a.start_with?("--") }
@@ -34,8 +53,11 @@ path, limit = args
34
53
  valid = case command
35
54
  when "cat" then flags.empty? && path && (limit.nil? || limit.match?(/\A\d+\z/))
36
55
  when "inspect"
37
- formats = flags & %w[--pages --json --html]
38
- path && limit.nil? && (flags - %w[--pages --json --html --verify-checksums]).empty? && formats.size <= 1
56
+ format_flags, other_flags = flags.partition { |f| f.start_with?("--format=") }
57
+ format = format_flags.empty? ? "text" : format_flags.first.delete_prefix("--format=")
58
+ path && limit.nil? && format_flags.size <= 1 && %w[text json html].include?(format) &&
59
+ (other_flags - %w[--pages --verify-checksums]).empty? &&
60
+ (format == "text" || !other_flags.include?("--pages"))
39
61
  end
40
62
  unless valid
41
63
  warn USAGE
@@ -51,11 +73,19 @@ begin
51
73
  else
52
74
  inspector = Herringbone::Inspector.new(io)
53
75
  inspector.verify_checksums if flags.include?("--verify-checksums")
54
- if flags.include?("--json")
76
+ case format
77
+ when "json"
55
78
  puts JSON.pretty_generate(inspector.to_h)
56
- elsif flags.include?("--html")
57
- $stdout.write(inspector.to_html)
58
- else
79
+ when "html"
80
+ if $stdout.tty?
81
+ html_path = File.join(Dir.tmpdir, "#{File.basename(path, ".*")}-#{Process.pid}.html")
82
+ File.binwrite(html_path, inspector.to_html)
83
+ puts html_path
84
+ warn "herringbone: could not open the page, open #{html_path} in a browser" unless open_with_default_app(html_path)
85
+ else
86
+ $stdout.write(inspector.to_html)
87
+ end
88
+ when "text"
59
89
  puts inspector.report(pages: flags.include?("--pages"))
60
90
  end
61
91
  end
@@ -11,13 +11,18 @@ module Herringbone
11
11
  # ActiveRecord is not required: this only uses what a model class exposes
12
12
  # (+columns+, +primary_key+ and, when present, +defined_enums+).
13
13
  #
14
- # only: attribute names to include (Strings or Symbols)
15
- # except: attribute names to leave out
16
- # parquet_enum: Rails enum attributes are string columns holding the labels (the writer
17
- # rejects values outside the enum; stored values such as 0/1 are written as
18
- # their labels). true adds the Parquet ENUM annotation, see Builder#enum.
14
+ # Primary key columns are never nullable. Rails enum attributes are string columns holding the
15
+ # labels (the writer rejects values outside the enum; stored values such as 0/1 are written as
16
+ # their labels).
17
+ #
18
+ # @param model [Class] ActiveRecord model class, or anything exposing +columns+ the same way
19
+ # @param only [Array<String, Symbol>, String, Symbol, nil] attribute names to include
20
+ # @param except [Array<String, Symbol>, String, Symbol, nil] attribute names to leave out
21
+ # @param parquet_enum [Boolean] true adds the Parquet ENUM annotation to enum columns, see Builder#enum
22
+ # @return [Schema] schema with one top-level field per selected column, in model column order
23
+ # @raise [ArgumentError] when no columns are left after +only+ / +except+
19
24
  def self.from_active_record(model, only: nil, except: nil, parquet_enum: false)
20
- only = only && Array(only).map(&:to_s)
25
+ only &&= Array(only).map(&:to_s)
21
26
  except = Array(except).map(&:to_s)
22
27
  defined_enums = model.respond_to?(:defined_enums) ? model.defined_enums.to_h { |k, v| [k.to_s, v] } : {}
23
28
  primary_keys = Array(model.respond_to?(:primary_key) ? model.primary_key : nil).map(&:to_s)
@@ -54,9 +59,14 @@ module Herringbone
54
59
  module ActiveRecordMapping
55
60
  module_function
56
61
 
62
+ # Precision for decimal columns that declare none (Postgres +numeric+ without arguments);
63
+ # 38 digits is the most a 16-byte FIXED_LEN_BYTE_ARRAY decimal holds
57
64
  DEFAULT_DECIMAL_PRECISION = 38
65
+ # Scale used together with DEFAULT_DECIMAL_PRECISION when the column declares neither
58
66
  DEFAULT_DECIMAL_SCALE = 9
59
67
 
68
+ # Bit width of named SQL integer types (MySQL, Postgres and SQLite spellings), keyed by the
69
+ # leading word of the lowercased +sql_type+
60
70
  INTEGER_SQL_TYPES = {
61
71
  "tinyint" => 8, "int1" => 8,
62
72
  "smallint" => 16, "int2" => 16, "smallserial" => 16, "serial2" => 16,
@@ -64,6 +74,16 @@ module Herringbone
64
74
  "bigint" => 64, "int8" => 64, "bigserial" => 64, "serial8" => 64
65
75
  }.freeze
66
76
 
77
+ # Declares +column+ on +builder+: hstore as map<string, string>, array columns as a list of
78
+ # the element type, everything else as the scalar type picked by #scalar_type.
79
+ #
80
+ # @param builder [Builder] builder to add the field to
81
+ # @param column [ActiveRecord::ConnectionAdapters::Column] column metadata (+name+, +type+, and
82
+ # when available +sql_type+, +array+, +limit+, +precision+, +scale+)
83
+ # @param nullable [Boolean] whether the field may be null
84
+ # @param primary [Boolean] whether the column is (part of) the primary key
85
+ # @return [Node] the added schema node
86
+ # @raise [ArgumentError] when the builder rejects the field (e.g. a duplicate name)
67
87
  def add_column(builder, column, nullable, primary)
68
88
  sql_type = column.respond_to?(:sql_type) ? column.sql_type.to_s.downcase : ""
69
89
  array = (column.respond_to?(:array) && column.array) || sql_type.end_with?("[]")
@@ -83,10 +103,18 @@ module Herringbone
83
103
  end
84
104
  end
85
105
 
106
+ # Picks the DSL type for a non-array, non-hstore column, following the table above.
107
+ #
108
+ # @param column [ActiveRecord::ConnectionAdapters::Column] column metadata, read for
109
+ # +limit+, +precision+ and +scale+
110
+ # @param type [Symbol, nil] ActiveRecord's abstract type (+column.type+)
111
+ # @param sql_type [String] lowercased database type with any array suffix removed
112
+ # @param primary [Boolean] whether the column is (part of) the primary key
113
+ # @return [Array(Symbol, Hash{Symbol => Object})] DSL type and its options for Builder#column
86
114
  def scalar_type(column, type, sql_type, primary)
87
115
  case type
88
116
  when :integer, :bigint then [integer_type(column, sql_type, primary), {}]
89
- when :float then [sql_type == "float4" ? :float : :double, {}]
117
+ when :float then [(sql_type == "float4") ? :float : :double, {}]
90
118
  when :decimal, :money
91
119
  precision = column.respond_to?(:precision) ? column.precision : nil
92
120
  scale = column.respond_to?(:scale) ? column.scale : nil
@@ -94,12 +122,12 @@ module Herringbone
94
122
  precision = DEFAULT_DECIMAL_PRECISION
95
123
  scale ||= DEFAULT_DECIMAL_SCALE
96
124
  end
97
- [:decimal, { precision: precision, scale: scale || 0 }]
125
+ [:decimal, {precision: precision, scale: scale || 0}]
98
126
  when :boolean then [:boolean, {}]
99
127
  when :binary then [:binary, {}]
100
128
  when :date then [:date, {}]
101
- when :datetime, :timestamp, :timestamptz then [:timestamp, { unit: :micros, utc: true }]
102
- when :time then [:time, { unit: :micros }]
129
+ when :datetime, :timestamp, :timestamptz then [:timestamp, {unit: :micros, utc: true}]
130
+ when :time then [:time, {unit: :micros}]
103
131
  when :json, :jsonb then [:json, {}]
104
132
  when :uuid then [:uuid, {}]
105
133
  else [:string, {}]
@@ -108,6 +136,11 @@ module Herringbone
108
136
 
109
137
  # Named SQL types (smallint, bigint, ...) decide the width; a generic "integer"/"int"
110
138
  # uses the column limit in bytes, since e.g. SQLite reports `t.integer limit: 2` as "integer(2)".
139
+ #
140
+ # @param column [ActiveRecord::ConnectionAdapters::Column] column metadata, read for +limit+
141
+ # @param sql_type [String] lowercased database type with any array suffix removed
142
+ # @param primary [Boolean] true forces int64, since row ids can outgrow the declared width
143
+ # @return [Symbol] one of +:int8+ .. +:int64+ or +:uint8+ .. +:uint64+
111
144
  def integer_type(column, sql_type, primary)
112
145
  bits = INTEGER_SQL_TYPES[sql_type[/\A[a-z0-9]+/]]
113
146
  bits ||= case (column.respond_to?(:limit) ? column.limit : nil)
@@ -120,9 +153,8 @@ module Herringbone
120
153
  bits = 64 if primary
121
154
  unsigned = sql_type.include?("unsigned")
122
155
  return :"uint#{bits}" if unsigned
123
- { 8 => :int8, 16 => :int16, 32 => :int32, 64 => :int64 }.fetch(bits)
156
+ {8 => :int8, 16 => :int16, 32 => :int32, 64 => :int64}.fetch(bits)
124
157
  end
125
158
  end
126
159
  end
127
160
  end
128
-