strong_csv 0.1.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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