strong_csv 0.1.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: 6a6324b338c8922470db715a4db9aac4d29a7e658313326df6b860a51eda09a5
4
- data.tar.gz: '095654b19ea937ca862c91b98e4c59d29f8ddd148d0cb2feac594edf1ea54a1f'
3
+ metadata.gz: 8078bf75d84307eb32f647a418d2cd2214d6891a78a25a73f7589b82fec0716c
4
+ data.tar.gz: 318af2a0c3a42ac7141819b06e729a5e98967c5106988bc4349b308f9c9d87f8
5
5
  SHA512:
6
- metadata.gz: 9d729e901fcbe7b6cecbe7b0470d5dffaa4c51f7a11269b1f0b2db188c633ee46fb569fab333343268d9f2235ce790508bc0070e6d716888bdfb8ae08c75011c
7
- data.tar.gz: 3cfc376f86564afea311afae002488172c9b01ec4676791e7c78b3192495361ddc9f9531a57987c7235c7420acf04819c265c29213a9282c23a93c45af5aae4f
6
+ metadata.gz: 63aa30f83182e0cab08f816124945d9da91149faaa82017ba0a80ca8b39ef57fed5eab521db1efd8404045aadba189550bd779f195434cef2294975dc30216f3
7
+ data.tar.gz: e4e74ea2446efcf32f6e67fe8be5df207ae8dbd66b392df4c2fb8895cd71aebf70894d062e64eee2814295aa00f29876afcb95b4c3a88ffffd7be0c8e9a9ba21
data/README.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # strong_csv
2
2
 
3
+ <a href="https://rubygems.org/gems/strong_csv"><img alt="strong_csv" src="https://img.shields.io/gem/v/strong_csv"></a>
4
+
3
5
  NOTE: This repository is still under development 🚧🚜🚧
4
6
 
5
7
  Type checker for a CSV file inspired by [strong_json](https://github.com/soutaro/strong_json).
6
8
 
7
- **Motivation**
9
+ ## Motivation
8
10
 
9
11
  Some applications have a feature to receive a CSV file uploaded by a user,
10
12
  and in general, it needs to validate each cell of the CSV file.
@@ -21,7 +23,7 @@ strong_json helps you to mitigate such a drudgery by letting you declare desired
21
23
  Add this line to your application's Gemfile:
22
24
 
23
25
  ```ruby
24
- gem 'strong_csv'
26
+ gem "strong_csv"
25
27
  ```
26
28
 
27
29
  And then execute:
@@ -38,22 +40,34 @@ gem install strong_csv
38
40
 
39
41
  ## Usage
40
42
 
41
- TBD: This hasn't yet been implemented.
43
+ The most important APIs of strong_csv are `StrongCSV.new` and `StrongCSV#parse`.
44
+ `StrongCSV.new` lets you declare types for each CSV column with Ruby's block syntax.
45
+ Inside the block, you will mainly use `let` and declare types for a column.
46
+
47
+ After defining types, you can parse CSV content with `StrongCSV#parse`.
48
+ `StrongCSV#parse` won't raise errors as possible and just store error messages in its rows.
49
+ The reason why it won't raise errors is CSV content may contain _invalid_ rows,
50
+ but sometimes, it makes sense to ignore them and process something for _valid_ rows.
51
+ If you want to stop all processes with invalid rows,
52
+ check whether all rows are valid before proceeding with computation.
53
+
54
+ Here is an example usage of this gem:
42
55
 
43
56
  ```ruby
57
+ require "strong_csv"
58
+
44
59
  strong_csv = StrongCSV.new do
45
60
  let :stock, integer
46
61
  let :tax_rate, float
47
- let :name, string(255)
48
- let :description, string?(1000)
62
+ let :name, string(within: 1..255)
63
+ let :description, string?(within: 1..1000)
49
64
  let :active, boolean
50
- let :started_at, time?
51
- let :data, any?
65
+ let :started_at, time?(format: "%Y-%m-%dT%H:%M:%S")
52
66
 
53
67
  # Literal declaration
54
68
  let :status, 0..6
55
- let :priority, 10 | 20 | 30 | 40 | 50
56
- let :size, "S" | "M" | "L" do |value|
69
+ let :priority, 10, 20, 30, 40, 50
70
+ let :size, "S", "M", "L" do |value|
57
71
  case value
58
72
  when "S"
59
73
  1
@@ -67,7 +81,9 @@ strong_csv = StrongCSV.new do
67
81
  # Regular expressions
68
82
  let :url, %r{\Ahttps://}
69
83
 
70
- # Custom validation.
84
+ # TODO: The followings are not implemented so far.
85
+
86
+ # Custom validation
71
87
  #
72
88
  # This example sees the database to fetch exactly stored `User` IDs,
73
89
  # and it checks the `:user_id` cell really exists in the `users` table.
@@ -75,7 +91,7 @@ strong_csv = StrongCSV.new do
75
91
  pick :user_id, as: :user_ids do |ids|
76
92
  User.where(id: ids).ids
77
93
  end
78
- let :user_id, integer { |i| user_ids.include?(i) }
94
+ let :user_id, integer(constraint: ->(i) { user_ids.include?(i) })
79
95
  end
80
96
 
81
97
  data = <<~CSV
@@ -89,7 +105,7 @@ strong_csv.parse(data, field_size_limit: 2048) do |row|
89
105
  row[:active] # => true
90
106
  # do something with row
91
107
  else
92
- row.errors # => [{ row: 2, column: :user_id, messages: ["must be present", "must be an Integer", "must satisfy the custom validation"] }]
108
+ row.errors # => { user_id: ["must be present", "must be an integer"] }
93
109
  # do something with row.errors
94
110
  end
95
111
  end
@@ -97,9 +113,312 @@ end
97
113
 
98
114
  ## Available types
99
115
 
100
- | Type | Description |
101
- | ------- | ----------------------------------- |
102
- | integer | The value must be casted to Integer |
116
+ <table>
117
+ <tr>
118
+ <th>Type</th>
119
+ <th>Description</th>
120
+ </tr>
121
+ <tr>
122
+ <td><a href="#integer-and-integer"><code>integer</code> and <code>integer?</code></a></td>
123
+ <td>The value must be casted to <code>Integer</code>.</td>
124
+ </tr>
125
+ <tr>
126
+ <td><a href="#float-and-float"><code>float</code> and <code>float?</code></a></td>
127
+ <td>The value must be casted to <code>Float</code>.</td>
128
+ </tr>
129
+ <tr>
130
+ <td><a href="#boolean-and-boolean"><code>boolean</code> and <code>boolean?</code></a></td>
131
+ <td>The value must be casted to Boolean (<code>true</code> or <code>false</code>).</td>
132
+ </tr>
133
+ <tr>
134
+ <td><a href="#string-and-string"><code>string</code> and <code>string?</code></a></td>
135
+ <td>The value must be casted to <code>String</code>.</td>
136
+ </tr>
137
+ <tr>
138
+ <td><a href="#time-and-time"><code>time</code> and <code>time?</code></a></td>
139
+ <td>The value must be casted to <code>Time</code>.</td>
140
+ </tr>
141
+ <tr>
142
+ <td><a href="#optional"><code>optional</code></a></td>
143
+ <td>The value can be <code>nil</code>. If the value exists, it must satisfy the given type constraint.</td>
144
+ </tr>
145
+ <tr>
146
+ <td><a href="#literal"><code>23</code> (Integer literal)</a></td>
147
+ <td>The value must be casted to the specific <code>Integer</code> literal.</td>
148
+ </tr>
149
+ <tr>
150
+ <td><a href="#literal"><code>15.12</code> (Float literal)</a></td>
151
+ <td>The value must be casted to the specific <code>Float</code> literal.</td>
152
+ </tr>
153
+ <tr>
154
+ <td><a href="#literal"><code>1..10</code> (Range literal)</a></td>
155
+ <td>The value must be casted to the beginning of <code>Range</code> and be covered with it.</td>
156
+ </tr>
157
+ <tr>
158
+ <td><a href="#literal"><code>"abc"</code> (String literal)</a></td>
159
+ <td>The value must be casted to the specific <code>String</code> literal.</td>
160
+ </tr>
161
+ <tr>
162
+ <td><a href="#literal"><code>%r{\Ahttps://}</code> (Regexp literal)</a></td>
163
+ <td>The value must be casted to a <code>String</code> that matches the specified Regexp.</td>
164
+ </tr>
165
+ <tr>
166
+ <td><a href="#union"><code>,</code> (Union type)</a></td>
167
+ <td>The value must satisfy one of the subtypes.</td>
168
+ </tr>
169
+ </table>
170
+
171
+ ### `integer` and `integer?`
172
+
173
+ The value must be casted to Integer. `integer?` allows the value to be `nil`, so you can declare optional integer type
174
+ for columns.
175
+
176
+ _Example_
177
+
178
+ ```ruby
179
+ strong_csv = StrongCSV.new do
180
+ let :stock, integer
181
+ let :state, integer?
182
+ end
183
+
184
+ result = strong_csv.parse(<<~CSV)
185
+ stock,state
186
+ 12,0
187
+ 20,
188
+ non-integer,1
189
+ CSV
190
+
191
+ result.map(&:valid?) # => [true, true, false]
192
+ result[0].slice(:stock, :state) # => {:stock=>12, :state=>0}
193
+ result[1].slice(:stock, :state) # => {:stock=>20, :state=>nil}
194
+ result[2].slice(:stock, :state) # => {:stock=>"non-integer", :state=>1} ("non-integer" cannot be casted to Integer)
195
+ ```
196
+
197
+ ### `float` and `float?`
198
+
199
+ The value must be casted to Float. `float?` allows the value to be `nil`, so you can declare optional float type for
200
+ columns.
201
+
202
+ _Example_
203
+
204
+ ```ruby
205
+ strong_csv = StrongCSV.new do
206
+ let :tax_rate, float
207
+ let :fail_rate, float?
208
+ end
209
+
210
+ result = strong_csv.parse(<<~CSV)
211
+ tax_rate,fail_rate
212
+ 0.02,0.1
213
+ 0.05,
214
+ ,0.8
215
+ CSV
216
+
217
+ result.map(&:valid?) # => [true, true, false]
218
+ result[0].slice(:tax_rate, :fail_rate) # => {:tax_rate=>0.02, :fail_rate=>0.1}
219
+ result[1].slice(:tax_rate, :fail_rate) # => {:tax_rate=>0.05, :fail_rate=>nil}
220
+ result[2].slice(:tax_rate, :fail_rate) # => {:tax_rate=>nil, :fail_rate=>0.8} (`nil` is not allowed for `tax_rate`)
221
+ ```
222
+
223
+ ### `boolean` and `boolean?`
224
+
225
+ The value must be casted to Boolean (`true` of `false`).
226
+ `"true"`, `"True"`, and `"TRUE"` are casted to `true`,
227
+ while `"false"`, `"False"`, and `"FALSE"` are casted to `false`.
228
+ `boolean?` allows the value to be `nil` as an optional boolean
229
+ value.
230
+
231
+ _Example_
232
+
233
+ ```ruby
234
+ strong_csv = StrongCSV.new do
235
+ let :enabled, boolean
236
+ let :active, boolean?
237
+ end
238
+
239
+ result = strong_csv.parse(<<~CSV)
240
+ enabled,active
241
+ True,True
242
+ False,
243
+ ,
244
+ CSV
245
+
246
+ result.map(&:valid?) # => [true, true, false]
247
+ result[0].slice(:enabled, :active) # => {:enabled=>true, :active=>true}
248
+ result[1].slice(:enabled, :active) # => {:enabled=>false, :active=>nil}
249
+ result[2].slice(:enabled, :active) # => {:enabled=>nil, :active=>nil} (`nil` is not allowed for `enabled`)
250
+ ```
251
+
252
+ ### `string` and `string?`
253
+
254
+ The value must be casted to String. `string?` allows the value to be `nil` as an optional string value.
255
+ They also support `:within` in its arguments, and it limits the length of the string value within the specified `Range`.
256
+
257
+ _Example_
258
+
259
+ ```ruby
260
+ strong_csv = StrongCSV.new do
261
+ let :name, string(within: 1..4)
262
+ let :description, string?
263
+ end
264
+
265
+ result = strong_csv.parse(<<~CSV)
266
+ name,description
267
+ JB,Hello
268
+ yykamei,
269
+ ,🤷
270
+ CSV
271
+
272
+ result.map(&:valid?) # => [true, false, false]
273
+ result[0].slice(:name, :description) # => {:name=>"JB", :description=>"Hello"}
274
+ result[1].slice(:name, :description) # => {:name=>"yykamei", :description=>nil} ("yykamei" exceeds the `Range` specified with `:within`)
275
+ result[2].slice(:name, :description) # => {:name=>nil, :description=>"🤷"} (`nil` is not allowed for `name`)
276
+ ```
277
+
278
+ ### `time` and `time?`
279
+
280
+ The value must be casted to Time. `time?` allows the value to be `nil` as an optional time value.
281
+ They have the `:format` argument, which is used as the format
282
+ of [`Time.strptime`](https://rubydoc.info/stdlib/time/Time.strptime);
283
+ it means you can ensure the value must satisfy the time format. The default value of `:format` is `"%Y-%m-%d"`.
284
+
285
+ _Example_
286
+
287
+ ```ruby
288
+ strong_csv = StrongCSV.new do
289
+ let :start_on, time
290
+ let :updated_at, time?(format: "%FT%T")
291
+ end
292
+
293
+ result = strong_csv.parse(<<~CSV)
294
+ start_on,updated_at
295
+ 2022-04-01,2022-04-30T15:30:59
296
+ 2022-05-03
297
+ 05-03,2021-09-03T09:48:23
298
+ CSV
299
+
300
+ result.map(&:valid?) # => [true, true, false]
301
+ result[0].slice(:start_on, :updated_at) # => {:start_on=>2022-04-01 00:00:00 +0900, :updated_at=>2022-04-30 15:30:59 +0900}
302
+ result[1].slice(:start_on, :updated_at) # => {:start_on=>2022-05-03 00:00:00 +0900, :updated_at=>nil}
303
+ result[2].slice(:start_on, :updated_at) # => {:start_on=>"05-03", :updated_at=>2021-09-03 09:48:23 +0900} ("05-03" does not satisfy the default format `"%Y-%m-%d"`)
304
+ ```
305
+
306
+ ### `optional`
307
+
308
+ While each type above has its optional type with `?`, literals cannot be suffixed with `?`.
309
+ However, there would be a case to have an optional literal type.
310
+ In this case, `optional` might be useful and lets you declare such types.
311
+
312
+ _Example_
313
+
314
+ ```ruby
315
+ strong_csv = StrongCSV.new do
316
+ let :foo, optional(123)
317
+ let :bar, optional("test")
318
+ end
319
+
320
+ result = strong_csv.parse(<<~CSV)
321
+ foo,bar
322
+ 123,test
323
+ ,
324
+ 124
325
+ CSV
326
+
327
+ result.map(&:valid?) # => [true, true, false]
328
+ result[0].slice(:foo, :bar) # => {:foo=>123, :bar=>"test"}
329
+ result[1].slice(:foo, :bar) # => {:foo=>nil, :bar=>nil}
330
+ result[2].slice(:foo, :bar) # => {:foo=>"124", :bar=>nil} (124 is not equal to 123)
331
+ ```
332
+
333
+ ### Literal
334
+
335
+ You can declare literal value as types. The supported literals are `Integer`, `Float`, `String`, and `Range`.
336
+
337
+ _Example_
338
+
339
+ ```ruby
340
+ strong_csv = StrongCSV.new do
341
+ let 0, 123
342
+ let 1, "test"
343
+ let 2, 2.5
344
+ let 3, 1..10
345
+ let 4, /[a-z]+/
346
+ end
347
+
348
+ result = strong_csv.parse(<<~CSV)
349
+ 123,test,2.5,9,abc
350
+ 123,test,2.5,0,xyz
351
+ 123,Hey,2.5,10,!
352
+ CSV
353
+
354
+ result.map(&:valid?) # => [true, false, false]
355
+ result[0].slice(0, 1, 2, 3, 4) # => {0=>123, 1=>"test", 2=>2.5, 3=>9, 4=>"abc"}
356
+ result[1].slice(0, 1, 2, 3, 4) # => {0=>123, 1=>"test", 2=>2.5, 3=>"0", 4=>"xyz"} (0 is out of 1..10)
357
+ 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]+/)
358
+ ```
359
+
360
+ ### Union
361
+
362
+ There would be a case that it's alright if a value satisfies one of the types.
363
+ Union types are useful for such a case.
364
+
365
+ _Example_
366
+
367
+ ```ruby
368
+ strong_csv = StrongCSV.new do
369
+ let :priority, 10, 20, 30
370
+ let :size, "S", "M", "L"
371
+ end
372
+
373
+ result = strong_csv.parse(<<~CSV)
374
+ priority,size
375
+ 10,M
376
+ 30,A
377
+ 11,S
378
+ CSV
379
+
380
+ result.map(&:valid?) # => [true, false, false]
381
+ result[0].slice(:priority, :size) # => {:priority=>10, :size=>"M"}
382
+ result[1].slice(:priority, :size) # => {:priority=>30, :size=>"A"} ("A" is not one of "S", "M", and "L")
383
+ result[2].slice(:priority, :size) # => {:priority=>"11", :size=>"S"} (11 is not one of 10, 20, and 30)
384
+ ```
385
+
386
+ ## I18n (Internationalization)
387
+
388
+ strong_csv depends on [i18n](https://rubygems.org/gems/i18n) for internationalization.
389
+ If you want to have a locale-specific error message, put the message catalog in your locale files.
390
+ Here is an example of a locale file.
391
+
392
+ ```yaml
393
+ ja:
394
+ strong_csv:
395
+ boolean:
396
+ cant_be_casted: "`%{value}`はBooleanに変換できません"
397
+ float:
398
+ cant_be_casted: "`%{value}`はFloatに変換できません"
399
+ integer:
400
+ cant_be_casted: "`%{value}`はIntegerに変換できません"
401
+ literal:
402
+ integer:
403
+ unexpected: "`%{expected}`ではなく`%{value}`が入力されています"
404
+ cant_be_casted: "`%{expected}`ではなく`%{value}`が入力されています"
405
+ float:
406
+ unexpected: "`%{expected}`ではなく`%{value}`が入力されています"
407
+ cant_be_casted: "`%{value}`はFloatに変換できません"
408
+ string:
409
+ unexpected: "`%{expected}`ではなく`%{value}`が入力されています"
410
+ range:
411
+ cant_be_casted: "`%{value}`は`%{expected}`の始端に変換できません"
412
+ out_of_range: "`%{value}`は`%{range}`の範囲外です"
413
+ regexp:
414
+ cant_be_casted: "`%{value}`はStringに変換できません"
415
+ unexpected: "`%{value}`は`%{expected}`とマッチしませんでした"
416
+ string:
417
+ cant_be_casted: "`%{value}`はStringに変換できません"
418
+ out_of_range: "`%{value}`の文字数は`%{range}`の範囲外です"
419
+ time:
420
+ cant_be_casted: "`%{value}`は`%{time_format}`でTimeに変換できません"
421
+ ```
103
422
 
104
423
  ## Contributing
105
424
 
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ I18n.backend.store_translations(
4
+ :en, {
5
+ _strong_csv: {
6
+ boolean: {
7
+ cant_be_casted: "`%{value}` can't be casted to Boolean",
8
+ },
9
+ float: {
10
+ cant_be_casted: "`%{value}` can't be casted to Float",
11
+ },
12
+ integer: {
13
+ cant_be_casted: "`%{value}` can't be casted to Integer",
14
+ },
15
+ literal: {
16
+ integer: {
17
+ unexpected: "`%{expected}` is expected, but `%{value}` was given",
18
+ cant_be_casted: "`%{value}` can't be casted to Integer",
19
+ },
20
+ float: {
21
+ unexpected: "`%{expected}` is expected, but `%{value}` was given",
22
+ cant_be_casted: "`%{value}` can't be casted to Float",
23
+ },
24
+ string: {
25
+ unexpected: "`%{expected}` is expected, but `%{value}` was given",
26
+ },
27
+ range: {
28
+ cant_be_casted: "`%{value}` can't be casted to the beginning of `%{expected}`",
29
+ out_of_range: "`%{value}` is not within `%{range}`",
30
+ },
31
+ regexp: {
32
+ cant_be_casted: "`%{value}` can't be casted to String",
33
+ unexpected: "`%{value}` did not match `%{expected}`",
34
+ },
35
+ },
36
+ string: {
37
+ cant_be_casted: "`%{value}` can't be casted to String",
38
+ out_of_range: "The length of `%{value}` is out of range `%{range}`",
39
+ },
40
+ time: {
41
+ cant_be_casted: "`%{value}` can't be casted to Time with the format `%{time_format}`",
42
+ },
43
+ },
44
+ },
45
+ )
@@ -3,7 +3,11 @@
3
3
  class StrongCSV
4
4
  # Let is a class that is used to define types for columns.
5
5
  class Let
6
- attr_reader :types, :headers
6
+ # @return [Hash{Symbol => [Types::Base, Proc]}]
7
+ attr_reader :types
8
+
9
+ # @return [Boolean]
10
+ attr_reader :headers
7
11
 
8
12
  def initialize
9
13
  @types = {}
@@ -11,12 +15,15 @@ class StrongCSV
11
15
  end
12
16
 
13
17
  # @param name [String, Symbol, Integer]
14
- def let(name, type)
18
+ # @param type [StrongCSV::Type::Base]
19
+ # @param types [Array<StrongCSV::Type::Base>]
20
+ def let(name, type, *types, &block)
21
+ type = types.empty? ? type : Types::Union.new(type, *types)
15
22
  case name
16
- when Integer
17
- @types[name] = type
18
- when String, Symbol
19
- @types[name.to_sym] = type
23
+ when ::Integer
24
+ @types[name] = [type, block]
25
+ when ::String, ::Symbol
26
+ @types[name.to_sym] = [type, block]
20
27
  else
21
28
  raise TypeError, "Invalid type specified for `name`. `name` must be String, Symbol, or Integer: #{name.inspect}"
22
29
  end
@@ -27,6 +34,49 @@ class StrongCSV
27
34
  Types::Integer.new
28
35
  end
29
36
 
37
+ def integer?
38
+ optional(integer)
39
+ end
40
+
41
+ def boolean
42
+ Types::Boolean.new
43
+ end
44
+
45
+ def boolean?
46
+ optional(boolean)
47
+ end
48
+
49
+ def float
50
+ Types::Float.new
51
+ end
52
+
53
+ def float?
54
+ optional(float)
55
+ end
56
+
57
+ # @param options [Hash] See `Types::String#initialize` for more details.
58
+ def string(**options)
59
+ Types::String.new(**options)
60
+ end
61
+
62
+ def string?(**options)
63
+ optional(string(**options))
64
+ end
65
+
66
+ # @param options [Hash] See `Types::Time#initialize` for more details.
67
+ def time(**options)
68
+ Types::Time.new(**options)
69
+ end
70
+
71
+ def time?(**options)
72
+ optional(time(**options))
73
+ end
74
+
75
+ # @param args [Array] See `Types::Optional#initialize` for more details.
76
+ def optional(*args)
77
+ Types::Optional.new(*args)
78
+ end
79
+
30
80
  private
31
81
 
32
82
  def validate_columns
@@ -4,8 +4,9 @@ class StrongCSV
4
4
  # Row is a representation of a row in a CSV file, which has casted values with specified types.
5
5
  class Row
6
6
  extend Forwardable
7
+ using Types::Literal
7
8
 
8
- def_delegators :@values, :[], :fetch
9
+ def_delegators :@values, :[], :fetch, :slice
9
10
 
10
11
  # @return [Hash]
11
12
  attr_reader :errors
@@ -20,10 +21,10 @@ class StrongCSV
20
21
  @values = {}
21
22
  @errors = {}
22
23
  @lineno = lineno
23
- types.each do |key, type|
24
+ types.each do |key, (type, block)|
24
25
  value_result = type.cast(row[key])
25
- @values[key] = value_result.value
26
- @errors[key] = value_result.error_message unless value_result.success?
26
+ @values[key] = block && value_result.success? ? block.call(value_result.value) : value_result.value
27
+ @errors[key] = value_result.error_messages unless value_result.success?
27
28
  end
28
29
  end
29
30
 
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StrongCSV
4
+ module Types
5
+ # Boolean type
6
+ class Boolean < Base
7
+ TRUE_VALUES = %w[true True TRUE].to_set.freeze
8
+ FALSE_VALUES = %w[false False FALSE].to_set.freeze
9
+ private_constant :TRUE_VALUES, :FALSE_VALUES
10
+
11
+ # @param value [Object] Value to be casted to Boolean
12
+ # @return [ValueResult]
13
+ def cast(value)
14
+ boolean = TRUE_VALUES.include?(value) ? true : nil
15
+ return ValueResult.new(value: boolean, original_value: value) unless boolean.nil?
16
+
17
+ boolean = FALSE_VALUES.include?(value) ? false : nil
18
+ return ValueResult.new(value: boolean, original_value: value) unless boolean.nil?
19
+
20
+ ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.boolean.cant_be_casted", value: value.inspect, default: :"_strong_csv.boolean.cant_be_casted")])
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StrongCSV
4
+ module Types
5
+ # Float type
6
+ class Float < Base
7
+ # @todo Use :exception for Float after we drop the support of Ruby 2.5
8
+ # @param value [Object] Value to be casted to Float
9
+ # @return [ValueResult]
10
+ def cast(value)
11
+ ValueResult.new(value: Float(value), original_value: value)
12
+ rescue ArgumentError, TypeError
13
+ ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.float.cant_be_casted", value: value.inspect, default: :"_strong_csv.float.cant_be_casted")])
14
+ end
15
+ end
16
+ end
17
+ end
@@ -5,10 +5,12 @@ class StrongCSV
5
5
  # Integer type
6
6
  class Integer < Base
7
7
  # @todo Use :exception for Integer after we drop the support of Ruby 2.5
8
+ # @param value [Object] Value to be casted to Integer
9
+ # @return [ValueResult]
8
10
  def cast(value)
9
11
  ValueResult.new(value: Integer(value), original_value: value)
10
12
  rescue ArgumentError, TypeError
11
- ValueResult.new(original_value: value, error_message: "`#{value.inspect}` can't be casted to Integer")
13
+ ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.integer.cant_be_casted", value: value.inspect, default: :"_strong_csv.integer.cant_be_casted")])
12
14
  end
13
15
  end
14
16
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StrongCSV
4
+ module Types
5
+ # Literal is a module to collect all the literal types for CSV parsing.
6
+ # This module is intended for refining built-in classes and provide `#cast` like `Base` class.
7
+ module Literal
8
+ refine ::Integer do
9
+ # @param value [Object] Value to be casted to Integer
10
+ # @return [ValueResult]
11
+ def cast(value)
12
+ int = Integer(value)
13
+ if int == self
14
+ ValueResult.new(value: int, original_value: value)
15
+ else
16
+ ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.integer.unexpected", value: int.inspect, expected: inspect, default: :"_strong_csv.literal.integer.unexpected")])
17
+ end
18
+ rescue ArgumentError, TypeError
19
+ ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.integer.cant_be_casted", value: value.inspect, default: :"_strong_csv.literal.integer.cant_be_casted")])
20
+ end
21
+ end
22
+
23
+ refine ::Float do
24
+ # @param value [Object] Value to be casted to Float
25
+ # @return [ValueResult]
26
+ def cast(value)
27
+ float = Float(value)
28
+ if float == self
29
+ ValueResult.new(value: float, original_value: value)
30
+ else
31
+ ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.float.unexpected", value: float.inspect, expected: inspect, default: :"_strong_csv.literal.float.unexpected")])
32
+ end
33
+ rescue ArgumentError, TypeError
34
+ ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.float.cant_be_casted", value: value.inspect, default: :"_strong_csv.literal.float.cant_be_casted")])
35
+ end
36
+ end
37
+
38
+ refine ::Range do
39
+ # @param value [Object] Value to be casted to Range
40
+ # @return [ValueResult]
41
+ def cast(value)
42
+ return ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.range.cant_be_casted", value: value.inspect, expected: inspect, default: :"_strong_csv.literal.range.cant_be_casted")]) if value.nil?
43
+
44
+ casted = case self.begin
45
+ when ::Float
46
+ Float(value)
47
+ when ::Integer
48
+ Integer(value)
49
+ when ::String
50
+ value
51
+ else
52
+ raise TypeError, "#{self.begin.class} is not supported"
53
+ end
54
+ if cover?(casted)
55
+ ValueResult.new(value: casted, original_value: value)
56
+ else
57
+ ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.range.out_of_range", value: casted.inspect, range: inspect, default: :"_strong_csv.literal.range.out_of_range")])
58
+ end
59
+ rescue ArgumentError
60
+ ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.range.cant_be_casted", value: value.inspect, expected: inspect, default: :"_strong_csv.literal.range.cant_be_casted")])
61
+ end
62
+ end
63
+
64
+ refine ::String do
65
+ # @param value [Object] Value to be casted to String
66
+ # @return [ValueResult]
67
+ def cast(value)
68
+ if self == value
69
+ ValueResult.new(value: self, original_value: value)
70
+ else
71
+ ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.string.unexpected", value: value.inspect, expected: inspect, default: :"_strong_csv.literal.string.unexpected")])
72
+ end
73
+ end
74
+ end
75
+
76
+ refine ::Regexp do
77
+ # @param value [Object] Value to be casted to String
78
+ # @return [ValueResult]
79
+ def cast(value)
80
+ return ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.regexp.cant_be_casted", value: value.inspect, default: :"_strong_csv.literal.regexp.cant_be_casted")]) if value.nil?
81
+
82
+ if self =~ value
83
+ ValueResult.new(value: value, original_value: value)
84
+ else
85
+ ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.literal.regexp.unexpected", value: value.inspect, expected: inspect, default: :"_strong_csv.literal.regexp.unexpected")])
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StrongCSV
4
+ module Types
5
+ # Optional type
6
+ class Optional < Base
7
+ using Types::Literal
8
+
9
+ # @param type [Base]
10
+ def initialize(type)
11
+ super()
12
+ @type = type
13
+ end
14
+
15
+ # @param value [Object]
16
+ # @return [ValueResult]
17
+ def cast(value)
18
+ value.nil? ? ValueResult.new(value: nil, original_value: value) : @type.cast(value)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StrongCSV
4
+ module Types
5
+ # String type
6
+ class String < Base
7
+ # @param within [Range, nil]
8
+ def initialize(within: nil)
9
+ raise ArgumentError, "`within` must be a Range" unless within.nil? || within.is_a?(Range)
10
+
11
+ super()
12
+ @within = within
13
+ end
14
+
15
+ # @param value [Object] Value to be casted to Boolean
16
+ # @return [ValueResult]
17
+ def cast(value)
18
+ return ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.string.cant_be_casted", value: value.inspect, default: :"_strong_csv.string.cant_be_casted")]) if value.nil?
19
+
20
+ casted = String(value)
21
+ if @within && !@within.cover?(casted.size)
22
+ ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.string.out_of_range", value: value.inspect, range: @within.inspect, default: :"_strong_csv.string.out_of_range")])
23
+ else
24
+ ValueResult.new(value: casted, original_value: value)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StrongCSV
4
+ module Types
5
+ # Time type
6
+ class Time < Base
7
+ # @param format [String] The format of #strptime
8
+ def initialize(format: "%Y-%m-%d")
9
+ raise ArgumentError, "`format` must be a String" unless format.is_a?(::String)
10
+
11
+ super()
12
+ @format = format
13
+ end
14
+
15
+ # @param value [Object] Value to be casted to Time
16
+ # @return [ValueResult]
17
+ def cast(value)
18
+ return ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.time.cant_be_casted", value: value.inspect, time_format: @format.inspect, default: :"_strong_csv.time.cant_be_casted")]) if value.nil?
19
+
20
+ ValueResult.new(value: ::Time.strptime(value.to_s, @format), original_value: value)
21
+ rescue ArgumentError
22
+ ValueResult.new(original_value: value, error_messages: [I18n.t("strong_csv.time.cant_be_casted", value: value.inspect, time_format: @format.inspect, default: :"_strong_csv.time.cant_be_casted")])
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StrongCSV
4
+ module Types
5
+ # Union type is a type that combine multiple types.
6
+ class Union < Base
7
+ using Types::Literal
8
+
9
+ # @param type [Base]
10
+ # @param types [Array<Base>]
11
+ def initialize(type, *types)
12
+ super()
13
+ @types = [type, *types]
14
+ end
15
+
16
+ # @param value [Object] Value to be casted to Integer
17
+ # @return [ValueResult]
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
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -6,13 +6,13 @@ class StrongCSV
6
6
  DEFAULT_VALUE = Object.new
7
7
  private_constant :DEFAULT_VALUE
8
8
 
9
- # @return [String, nil] The error message for the field.
10
- attr_reader :error_message
9
+ # @return [Array<String>, nil] The error messages for the field.
10
+ attr_reader :error_messages
11
11
 
12
- def initialize(original_value:, value: DEFAULT_VALUE, error_message: nil)
12
+ def initialize(original_value:, value: DEFAULT_VALUE, error_messages: nil)
13
13
  @value = value
14
14
  @original_value = original_value
15
- @error_message = error_message
15
+ @error_messages = error_messages
16
16
  end
17
17
 
18
18
  # @return [Object] The casted value if it's valid. Otherwise, returns the original value.
@@ -22,7 +22,7 @@ class StrongCSV
22
22
 
23
23
  # @return [Boolean]
24
24
  def success?
25
- @error_message.nil?
25
+ @error_messages.nil?
26
26
  end
27
27
  end
28
28
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class StrongCSV
4
- VERSION = "0.1.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/strong_csv.rb CHANGED
@@ -2,12 +2,23 @@
2
2
 
3
3
  require "csv"
4
4
  require "forwardable"
5
+ require "set"
6
+ require "time"
7
+ require "i18n"
5
8
 
6
9
  require_relative "strong_csv/version"
7
- require_relative "strong_csv/let"
10
+ require_relative "strong_csv/i18n"
8
11
  require_relative "strong_csv/value_result"
9
12
  require_relative "strong_csv/types/base"
13
+ require_relative "strong_csv/types/boolean"
14
+ require_relative "strong_csv/types/float"
10
15
  require_relative "strong_csv/types/integer"
16
+ require_relative "strong_csv/types/literal"
17
+ require_relative "strong_csv/types/optional"
18
+ require_relative "strong_csv/types/string"
19
+ require_relative "strong_csv/types/time"
20
+ require_relative "strong_csv/types/union"
21
+ require_relative "strong_csv/let"
11
22
  require_relative "strong_csv/row"
12
23
 
13
24
  # The top-level namespace for the strong_csv gem.
@@ -22,6 +33,8 @@ class StrongCSV
22
33
  # @param csv [String, IO]
23
34
  # @param options [Hash] CSV options for parsing.
24
35
  def parse(csv, **options)
36
+ # NOTE: Some options are overridden here to ensure that StrongCSV can handle parsed values correctly.
37
+ options.delete(:nil_value)
25
38
  options = options.merge(headers: @let.headers, header_converters: :symbol)
26
39
  csv = CSV.new(csv, **options)
27
40
  if block_given?
metadata CHANGED
@@ -1,15 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strong_csv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yutaka Kamei
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2022-04-26 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2022-05-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: i18n
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 1.8.11
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '2'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 1.8.11
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '2'
13
33
  description: strong_csv is a type checker for a CSV file. It lets developers declare
14
34
  types for each column to ensure all cells are satisfied with desired types.
15
35
  email:
@@ -21,10 +41,18 @@ files:
21
41
  - LICENSE
22
42
  - README.md
23
43
  - lib/strong_csv.rb
44
+ - lib/strong_csv/i18n.rb
24
45
  - lib/strong_csv/let.rb
25
46
  - lib/strong_csv/row.rb
26
47
  - lib/strong_csv/types/base.rb
48
+ - lib/strong_csv/types/boolean.rb
49
+ - lib/strong_csv/types/float.rb
27
50
  - lib/strong_csv/types/integer.rb
51
+ - lib/strong_csv/types/literal.rb
52
+ - lib/strong_csv/types/optional.rb
53
+ - lib/strong_csv/types/string.rb
54
+ - lib/strong_csv/types/time.rb
55
+ - lib/strong_csv/types/union.rb
28
56
  - lib/strong_csv/value_result.rb
29
57
  - lib/strong_csv/version.rb
30
58
  homepage: https://github.com/yykamei/strong_csv