strong_csv 0.8.5 → 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 +4 -4
- data/README.md +26 -1
- data/lib/strong_csv/let.rb +23 -4
- data/lib/strong_csv/type_wrapper.rb +1 -1
- data/lib/strong_csv/types/literal.rb +45 -3
- data/lib/strong_csv/types/time.rb +89 -3
- data/lib/strong_csv/types/union.rb +8 -4
- data/lib/strong_csv/version.rb +1 -1
- data/lib/strong_csv.rb +1 -4
- data/sig/strong_csv.rbs +11 -2
- 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: dd735e06083068bf676047a6ac4f2a0441bc3b36cb3455e04ac7ae2c0206acb6
|
|
4
|
+
data.tar.gz: 77be5288dbd7854f82385367b62352c4ac98e41f108046c99fc68dadc8611890
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
data/lib/strong_csv/let.rb
CHANGED
|
@@ -17,11 +17,12 @@ 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]
|
|
23
|
-
# @param type [StrongCSV::
|
|
24
|
-
# @param types [Array<StrongCSV::
|
|
24
|
+
# @param type [StrongCSV::Types::Base]
|
|
25
|
+
# @param types [Array<StrongCSV::Types::Base>]
|
|
25
26
|
def let(name, type, *types, error_message: nil, &block)
|
|
26
27
|
type = Types::Union.new(type, *types) unless types.empty?
|
|
27
28
|
case name
|
|
@@ -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 |
|
|
55
|
-
@picked[as] = block.call(
|
|
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.
|
|
@@ -9,7 +9,7 @@ class StrongCSV # rubocop:disable Style/Documentation
|
|
|
9
9
|
# @!attribute name
|
|
10
10
|
# @return [Symbol, Integer] The name for the type. This is the CSV header name. If the CSV does not have its header, Integer should be set.
|
|
11
11
|
# @!attribute type
|
|
12
|
-
# @return [StrongCSV::
|
|
12
|
+
# @return [StrongCSV::Types::Base]
|
|
13
13
|
# @!attribute error_message
|
|
14
14
|
# @return [String, nil] The error message returned if #cast fails. If omitted, the default error message will be used.
|
|
15
15
|
# @!attribute block
|
|
@@ -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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
data/lib/strong_csv/version.rb
CHANGED
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.
|
|
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
|
@@ -22,9 +22,9 @@ class StrongCSV
|
|
|
22
22
|
@value: (casted)?
|
|
23
23
|
@original_value: untyped
|
|
24
24
|
|
|
25
|
-
attr_reader error_messages: [::String]?
|
|
25
|
+
attr_reader error_messages: Array[::String]?
|
|
26
26
|
|
|
27
|
-
def initialize: (original_value: ::String | nil, ?value: (casted | ::Object)?, ?error_messages: [::String]?) -> void
|
|
27
|
+
def initialize: (original_value: ::String | nil, ?value: (casted | ::Object)?, ?error_messages: Array[::String]?) -> void
|
|
28
28
|
|
|
29
29
|
def value: -> ((casted | ::Object | nil)?)
|
|
30
30
|
|
|
@@ -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.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yutaka Kamei
|
|
@@ -83,7 +83,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
83
83
|
- !ruby/object:Gem::Version
|
|
84
84
|
version: '0'
|
|
85
85
|
requirements: []
|
|
86
|
-
rubygems_version: 3.6.
|
|
86
|
+
rubygems_version: 3.6.9
|
|
87
87
|
specification_version: 4
|
|
88
88
|
summary: Type check CSV objects
|
|
89
89
|
test_files: []
|