strong_csv 0.8.6 → 0.9.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: 642cc6ac08fe76d56643c190d645fc65f4c5d8a7dbe8a304ad38c265918ae40e
4
- data.tar.gz: b83f3232be02019f69ee13774a2b0299bacb25810ed7b7b35bd5b2e68126332f
3
+ metadata.gz: dd735e06083068bf676047a6ac4f2a0441bc3b36cb3455e04ac7ae2c0206acb6
4
+ data.tar.gz: 77be5288dbd7854f82385367b62352c4ac98e41f108046c99fc68dadc8611890
5
5
  SHA512:
6
- metadata.gz: a9d429bfef2e5c40ff3c1b1e9d37f299f2f0f66607b5fc513357a1d03b8b20a16721ead6b12c1a7e8533ad8b31d8c35fea777c870a70c6ee9cfd8b5a912646dc
7
- data.tar.gz: 4d393b455dbcd89a8a6fd051f781c6776ed5ae5c802ab582898843457748fb37d10d0cdd94c7b430a89decd9532bc9e6267a02d402433e00ea15fcdf00833612
6
+ metadata.gz: 3d2bbc51b2006f8769ce112c3a4280f6ca0000e5503680716cd218eb06db1011805ab7ff6113ad9804f58e26af03921f9a1f188f1626a7e6a2715663df9fa082
7
+ data.tar.gz: 2e531d2ecbe4e9b035a882701a1eedd342ccf6bacea712964517d68b56f73ecf0654afb26229e6e1596bfb7d77805cf09c6c5d6d1e67ba337eecc94f57fedb22
data/README.md CHANGED
@@ -108,6 +108,11 @@ strong_csv.parse(data, field_size_limit: 2048) do |row|
108
108
  end
109
109
  ```
110
110
 
111
+ `pick` buffers all values of the specified column in memory before invoking the block,
112
+ because the block receives the complete array. For very large CSV files, this can consume
113
+ a large amount of memory; the streaming form of `StrongCSV#parse` does not remove this
114
+ buffering for picked columns.
115
+
111
116
  You can also define types without CSV headers by specifying column numbers.
112
117
 
113
118
  > [!NOTE]
@@ -302,7 +307,19 @@ result[2].slice(:name, :description) # => {:name=>nil, :description=>"🤷"} (`n
302
307
  The value must be casted to Time. `time?` allows the value to be `nil` as an optional time value.
303
308
  They have the `:format` argument, which is used as the format
304
309
  of [`Time.strptime`](https://rubydoc.info/stdlib/time/Time.strptime);
305
- it means you can ensure the value must satisfy the time format. The default value of `:format` is `"%Y-%m-%d"`.
310
+ it means you can ensure the value must satisfy the time format.
311
+ The whole value must match the format; trailing characters are rejected.
312
+ A value with a timezone directive is invalid when the zone cannot be
313
+ resolved to an offset; numeric offsets must use a supported form such as
314
+ `+05`, `+0530`, `+05:30`, or `+05:30:45`. Use `%z` for numeric offsets
315
+ and `%Z` for named zones. Zone-bearing
316
+ formats other than `%s` or `%Q` must include a year, and day-of-year
317
+ and week-date directives are not supported with a timezone. Fractional
318
+ epoch values with a zero whole-second part are invalid. Zone names
319
+ follow Ruby's built-in abbreviation table, so prefer a numeric offset
320
+ when the offset must be unambiguous. Values without a timezone directive
321
+ are interpreted in the process timezone. The default value of `:format`
322
+ is `"%Y-%m-%d"`.
306
323
 
307
324
  _Example_
308
325
 
@@ -379,6 +396,14 @@ result[1].slice(0, 1, 2, 3, 4) # => {0=>123, 1=>"test", 2=>2.5, 3=>"0", 4=>"xyz"
379
396
  result[2].slice(0, 1, 2, 3, 4) # => {0=>123, 1=>"Hey", 2=>2.5, 3=>10, 4=>"!"} ("Hey" is not equal to "test", and "!" does not match /[a-z]+/)
380
397
  ```
381
398
 
399
+ > [!NOTE]
400
+ > On Ruby 3.2 and later, Regexp literals are matched with a one-second timeout per match by
401
+ > default. A match that exceeds the timeout is treated as a non-match. On Ruby 3.2 and later,
402
+ > pass a Regexp with an explicit timeout to change the limit, e.g.
403
+ > `let :id, Regexp.new("\\A[a-z]+\\z", timeout: 5.0)`. A per-Regexp timeout or the global
404
+ > `Regexp.timeout` takes precedence over the default. Ruby 3.1 and earlier have no regexp
405
+ > timeout, and the `timeout:` keyword must not be used there because it is not supported.
406
+
382
407
  ### Union
383
408
 
384
409
  There would be a case that it's alright if a value satisfies one of the types.
@@ -17,6 +17,7 @@ class StrongCSV
17
17
  @headers = false
18
18
  @pickers = {}
19
19
  @picked = {}
20
+ @picker_columns = {}
20
21
  end
21
22
 
22
23
  # @param name [String, Symbol, Integer]
@@ -39,6 +40,9 @@ class StrongCSV
39
40
  # This might be useful for the case where you want to receive IDs that are stored in a database,
40
41
  # and you want to make sure the IDs actually exist.
41
42
  #
43
+ # The block receives all values of the column at once, so they are buffered in memory
44
+ # for the entire CSV.
45
+ #
42
46
  # @example
43
47
  # pick :user_id, as: :user_ids do |user_ids|
44
48
  # User.where(id: user_ids).ids
@@ -51,9 +55,24 @@ class StrongCSV
51
55
  define_singleton_method(as) do
52
56
  @picked[as]
53
57
  end
54
- @pickers[as] = lambda do |csv|
55
- @picked[as] = block.call(csv.map { |row| row[column] })
58
+ @pickers[as] = lambda do |values|
59
+ @picked[as] = block.call(values)
56
60
  end
61
+ @picker_columns[as] = column
62
+ end
63
+
64
+ # It collects the values for each picker in a single pass over the CSV,
65
+ # then invokes the picker blocks with the collected values.
66
+ # The collected values are held in memory until the picker blocks return.
67
+ # The CSV stream is rewound before returning so the caller can continue
68
+ # reading from the beginning.
69
+ def pick_all(csv)
70
+ return if @pickers.empty?
71
+
72
+ buffers = Hash.new { |hash, key| hash[key] = [] }
73
+ csv.each { |row| @pickers.each_key { |as| buffers[as] << row[@picker_columns[as]] } }
74
+ csv.rewind
75
+ @pickers.each { |as, picker| picker.call(buffers[as]) }
57
76
  end
58
77
 
59
78
  # @param options [Hash] See `Types::Integer#initialize` for more details.
@@ -5,6 +5,45 @@ class StrongCSV
5
5
  # Literal is a module to collect all the literal types for CSV parsing.
6
6
  # This module is intended for refining built-in classes and provide `#cast` like `Base` class.
7
7
  module Literal
8
+ # The default time limit, in seconds, for matching a Regexp literal on Ruby 3.2 and later.
9
+ REGEXP_TIMEOUT = 1.0
10
+ private_constant :REGEXP_TIMEOUT
11
+
12
+ class << self
13
+ # Returns a Regexp that enforces the default match timeout.
14
+ #
15
+ # The original Regexp is returned when the running Ruby has no regexp
16
+ # timeout support, or when a per-Regexp or global timeout is already set.
17
+ #
18
+ # @param regexp [Regexp]
19
+ # @return [Regexp]
20
+ def regexp_with_timeout(regexp)
21
+ return regexp unless defined?(::Regexp::TimeoutError)
22
+ return regexp if regexp.timeout || ::Regexp.timeout
23
+
24
+ ::Regexp.new(regexp.source, regexp.options, timeout: REGEXP_TIMEOUT)
25
+ end
26
+
27
+ # Matches the value against the Regexp with the default timeout.
28
+ #
29
+ # @param regexp [Regexp]
30
+ # @param value [Object]
31
+ # @return [Integer, nil, :timeout] The match position, nil, or :timeout when the match exceeded the limit
32
+ def regexp_match(regexp, value)
33
+ matcher = regexp_with_timeout(regexp)
34
+
35
+ if defined?(::Regexp::TimeoutError)
36
+ begin
37
+ matcher =~ value
38
+ rescue ::Regexp::TimeoutError
39
+ :timeout
40
+ end
41
+ else
42
+ matcher =~ value
43
+ end
44
+ end
45
+ end
46
+
8
47
  refine ::Integer do
9
48
  # @param value [Object] Value to be casted to Integer
10
49
  # @return [ValueResult]
@@ -79,10 +118,13 @@ class StrongCSV
79
118
  def cast(value)
80
119
  return ValueResult.new(original_value: value, error_messages: ["`#{value.inspect}` can't be casted to String"]) if value.nil?
81
120
 
82
- if self =~ value
83
- ValueResult.new(value: value, original_value: value)
84
- else
121
+ case Literal.regexp_match(self, value)
122
+ when :timeout
123
+ ValueResult.new(original_value: value, error_messages: ["`#{inspect}` matching timed out"])
124
+ when nil
85
125
  ValueResult.new(original_value: value, error_messages: ["`#{value.inspect}` did not match `#{inspect}`"])
126
+ else
127
+ ValueResult.new(value: value, original_value: value)
86
128
  end
87
129
  end
88
130
  end
@@ -4,23 +4,109 @@ class StrongCSV
4
4
  module Types
5
5
  # Time type
6
6
  class Time < Base
7
+ # Matches the numeric offset forms accepted by this type: +05, +0530,
8
+ # +05:30, +05:30:45, and the same forms with a GMT/UTC prefix.
9
+ OFFSET_TOKEN = /\A(?:gmt|utc)?[+-](?:[01]\d|2[0-3])(?::[0-5]\d(?::[0-5]\d)?|[0-5]\d(?:[0-5]\d)?)?\z/i.freeze
10
+ private_constant :OFFSET_TOKEN
11
+
7
12
  # @param format [String] The format of #strptime
8
13
  def initialize(format: "%Y-%m-%d")
9
14
  raise ArgumentError, "`format` must be a String" unless format.is_a?(::String)
10
15
 
11
16
  super()
12
17
  @format = format
18
+ @zone_name_allowed = format.include?("%Z")
13
19
  end
14
20
 
15
21
  # @param value [Object] Value to be casted to Time
16
22
  # @return [ValueResult]
17
23
  def cast(value)
18
- return ValueResult.new(original_value: value, error_messages: ["`#{value.inspect}` can't be casted to Time with the format `#{@format.inspect}`"]) if value.nil?
24
+ return failure(value) if value.nil?
25
+
26
+ string = value.to_s
27
+ # Time.strptime stops at the end of the format and ignores the rest,
28
+ # so check the unconsumed suffix reported by Date._strptime first.
29
+ parsed = ::Date._strptime(string, @format)
30
+ return failure(value) unless parsed_valid?(parsed)
31
+
32
+ time = parsed.key?(:zone) ? build_zoned_time(parsed) : ::Time.strptime(string, @format)
33
+ return failure(value) if time.nil?
34
+
35
+ ValueResult.new(value: time, original_value: value)
36
+ rescue ArgumentError, RangeError
37
+ failure(value)
38
+ end
39
+
40
+ private
19
41
 
20
- ValueResult.new(value: ::Time.strptime(value.to_s, @format), original_value: value)
21
- rescue ArgumentError
42
+ def failure(value)
22
43
  ValueResult.new(original_value: value, error_messages: ["`#{value.inspect}` can't be casted to Time with the format `#{@format.inspect}`"])
23
44
  end
45
+
46
+ def parsed_valid?(parsed)
47
+ return false if parsed.nil? || parsed[:leftover]
48
+ return false if ambiguous_epoch?(parsed)
49
+
50
+ !invalid_zone?(parsed)
51
+ end
52
+
53
+ # Date._strptime loses the sign of a fractional epoch whose whole-second
54
+ # part is zero, so such values cannot be casted reliably.
55
+ def ambiguous_epoch?(parsed)
56
+ parsed.key?(:seconds) && parsed[:seconds].zero? && parsed.fetch(:sec_fraction, 0).positive?
57
+ end
58
+
59
+ # Date._strptime accepts loose zone tokens and may leave :offset nil,
60
+ # in which case Time.strptime silently uses the local timezone.
61
+ def invalid_zone?(parsed)
62
+ zone = parsed[:zone]
63
+ return false if zone.nil?
64
+ return true if parsed[:offset].nil?
65
+ return true unless valid_zone_token?(zone)
66
+
67
+ false
68
+ end
69
+
70
+ def valid_zone_token?(zone)
71
+ return OFFSET_TOKEN.match?(zone) if zone.match?(/\d/)
72
+ return true if zone == "Z"
73
+
74
+ @zone_name_allowed
75
+ end
76
+
77
+ # Build the Time from the parsed fields so the result does not depend on
78
+ # the host timezone or its DST rules. Zone-bearing formats other than epoch
79
+ # directives must include a year; day-of-year and week-date directives are
80
+ # not supported with a zone.
81
+ def build_zoned_time(parsed)
82
+ return nil if unsupported_date_fields?(parsed)
83
+
84
+ offset = parsed[:offset]
85
+ return build_zoned_epoch(parsed, offset) if parsed.key?(:seconds)
86
+ return nil unless parsed.key?(:year)
87
+
88
+ time = ::Time.new(parsed[:year], parsed.fetch(:mon, 1), parsed.fetch(:mday, 1), 0, 0, 0, offset) + seconds_of_day(parsed)
89
+ utc_time(time, offset)
90
+ end
91
+
92
+ def build_zoned_epoch(parsed, offset)
93
+ seconds = parsed[:seconds]
94
+ fraction = parsed.fetch(:sec_fraction, 0)
95
+ seconds += seconds.negative? ? -fraction : fraction
96
+ utc_time(::Time.at(seconds).getlocal(offset), offset)
97
+ end
98
+
99
+ def seconds_of_day(parsed)
100
+ (parsed.fetch(:hour, 0) * 3600) + (parsed.fetch(:min, 0) * 60) + parsed.fetch(:sec, 0) + parsed.fetch(:sec_fraction, 0)
101
+ end
102
+
103
+ def utc_time(time, offset)
104
+ offset.zero? ? time.utc : time
105
+ end
106
+
107
+ def unsupported_date_fields?(parsed)
108
+ parsed.key?(:yday) || parsed.key?(:cwyear) || parsed.key?(:cweek) || parsed.key?(:cwday) || parsed.key?(:wnum0) || parsed.key?(:wnum1)
109
+ end
24
110
  end
25
111
  end
26
112
  end
@@ -16,11 +16,15 @@ class StrongCSV
16
16
  # @param value [Object] Value to be casted to Integer
17
17
  # @return [ValueResult]
18
18
  def cast(value)
19
- results = @types.map { |type| type.cast(value) }
20
- results.find(&:success?) || results.reduce do |memo, result|
21
- memo.error_messages.concat(result.error_messages).uniq!
22
- memo
19
+ error_messages = nil
20
+ @types.each do |type|
21
+ result = type.cast(value)
22
+ return result if result.success?
23
+
24
+ error_messages = (error_messages || []).concat(result.error_messages).uniq
23
25
  end
26
+
27
+ ValueResult.new(original_value: value, error_messages: error_messages)
24
28
  end
25
29
  end
26
30
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class StrongCSV
4
- VERSION = "0.8.6"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/strong_csv.rb CHANGED
@@ -52,10 +52,7 @@ class StrongCSV
52
52
  options = options.merge(headers: @let.headers, header_converters: :symbol)
53
53
  csv = CSV.new(csv, **options)
54
54
 
55
- @let.pickers.each_value do |picker|
56
- picker.call(csv)
57
- csv.rewind
58
- end
55
+ @let.pick_all(csv)
59
56
 
60
57
  if block_given?
61
58
  csv.each do |row|
data/sig/strong_csv.rbs CHANGED
@@ -64,6 +64,12 @@ class StrongCSV
64
64
  end
65
65
 
66
66
  module Literal
67
+ REGEXP_TIMEOUT: ::Float
68
+
69
+ def self.regexp_with_timeout: (::Regexp) -> ::Regexp
70
+
71
+ def self.regexp_match: (::Regexp, untyped) -> (::Integer | nil | :timeout)
72
+
67
73
  def cast: (untyped value) -> ValueResult
68
74
  | (untyped value) -> ValueResult
69
75
  | (untyped value) -> ValueResult
@@ -106,6 +112,7 @@ class StrongCSV
106
112
 
107
113
  class Let
108
114
  @picked: Hash[untyped, untyped]
115
+ @picker_columns: Hash[untyped, column]
109
116
 
110
117
  attr_reader types: Array[TypeWrapper]
111
118
  attr_reader headers: bool
@@ -117,6 +124,8 @@ class StrongCSV
117
124
 
118
125
  def pick: (column, as: ::Symbol) { (Array[::String]) -> untyped } -> void
119
126
 
127
+ def pick_all: (untyped) -> void
128
+
120
129
  def integer: (**untyped) -> Types::Integer
121
130
 
122
131
  def integer?: (**untyped) -> Types::Optional
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strong_csv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.6
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yutaka Kamei